mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
fix(honcho): plug pinPeerName transition gaps
Three correctness gaps when honcho.json's identity-mapping config changes mid-flight: 1. The gateway's agent cache signature ignored honcho identity keys, so editing peerName / pinPeerName / userPeerAliases / runtimePeerPrefix was silently dropped until an unrelated cache eviction. Extend _extract_cache_busting_config to fingerprint the resolved honcho config so the AIAgent rebuilds on the next message. 2. cmd_setup let single → multi flips orphan the pinned-pool history under peerName without warning. Detect the transition, warn that runtime users will resolve to fresh empty peers, and auto-steer to hybrid (alias the operator's runtime IDs back to peerName) so the operator's own continuity survives. yes / no overrides available. 3. README didn't document the orphaning behaviour. Add a "Migrating single → multi" callout under Deployment shapes. Tests: - TestPinTransition (test_pin_peer_name.py): fresh-manager flip resolves to runtime, in-process flip is gated by the per-key session cache (documents the gateway-cache-must-bust contract), 3 cache-bust signature tests for pin / aliases / prefix. - TestProfilePeerUniqueness: two profiles pinned to distinct peerNames resolve to distinct peers; host-level peerName overrides root when pinned. - test_single_to_multi_steers_to_hybrid_by_default and test_single_to_multi_yes_override_keeps_multi (test_cli.py): wizard guard end-to-end coverage.
This commit is contained in:
parent
58987cb8b1
commit
6feb2afd50
5 changed files with 274 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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_"
|
||||
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_"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue