fix(honcho): cover pinUserPeer + aiPeer edge cases in setup, clone, and gateway cache

Three related regressions stemming from the pinUserPeer alias landing:

- Setup wizard read host-only fields when detecting current shape but the
  parser supports root-level config and gives host pinUserPeer higher
  precedence than pinPeerName. Re-running setup could mis-detect shape
  and silently flip routing. Detection now uses the same resolver order
  as HonchoClientConfig, and each shape branch scrubs every peer-mapping
  key before writing so a stale pinUserPeer=false can't outrank a freshly
  written pinPeerName=true. Multi no longer auto-writes
  userPeerAliases={} (was silently masking root-level baselines).

- clone_honcho_for_profile inherited pinPeerName but not pinUserPeer, so
  a default profile configured with the newer key produced cloned
  profiles without the pin.

- Gateway cache-busting signature fingerprinted Honcho user-peer fields
  but not ai_peer. Since HonchoSessionManager freezes cfg.ai_peer at
  init, mid-flight aiPeer edits kept assistant writes on the old peer
  until an unrelated cache eviction. ai_peer is now part of the
  signature.
This commit is contained in:
Erosika 2026-05-26 14:20:01 -04:00 committed by kshitij
parent 939499beed
commit 1a8e67076a
4 changed files with 296 additions and 30 deletions

View file

@ -41,17 +41,19 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
return False # already exists
# Clone settings from default block, override identity fields.
# Identity-mapping keys (pinPeerName, userPeerAliases, runtimePeerPrefix)
# carry the operator's runtime-to-peer routing intent from #27371.
# Without them in this allowlist, a cloned profile would silently lose
# the mapping and gateway users would resolve to raw runtime IDs,
# fragmenting Honcho memory across an unintended set of peers.
# Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases,
# runtimePeerPrefix) carry the operator's runtime-to-peer routing
# intent from #27371. Both pin keys are inherited because
# HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving
# the canonical key off this allowlist silently drops the pin on
# cloned profiles when the default uses the newer name.
new_block = {}
for key in ("recallMode", "writeFrequency", "sessionStrategy",
"sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel",
"dialecticDynamic", "dialecticMaxChars", "messageMaxChars",
"dialecticMaxInputChars", "saveMessages", "observation",
"pinPeerName", "userPeerAliases", "runtimePeerPrefix"):
"pinPeerName", "pinUserPeer", "userPeerAliases",
"runtimePeerPrefix"):
val = default_block.get(key)
if val is not None:
new_block[key] = val
@ -314,6 +316,72 @@ def _resolve_api_key(cfg: dict) -> str:
return key
_IDENTITY_MAPPING_KEYS = (
"pinPeerName",
"pinUserPeer",
"userPeerAliases",
"runtimePeerPrefix",
)
def _resolve_effective_identity_mapping(
cfg: dict, hermes_host: dict
) -> tuple[bool, dict, str, bool, bool]:
"""Resolve the effective identity-mapping state for the active host.
Matches the precedence used by ``HonchoClientConfig.from_global_config``
so the wizard reads the same shape the gateway will actually run with.
Without this, root-level overrides and ``pinUserPeer`` (which wins over
``pinPeerName`` at the same level) are invisible to detection, letting
setup mis-classify the current shape and silently change effective
routing on the next save.
Returns ``(pin, aliases, prefix, aliases_from_root, prefix_from_root)``.
The ``*_from_root`` flags let the write step skip touching host keys
whose value is actually inherited.
"""
pin = False
for val in (
hermes_host.get("pinUserPeer"),
hermes_host.get("pinPeerName"),
cfg.get("pinUserPeer"),
cfg.get("pinPeerName"),
):
if val is not None:
pin = bool(val)
break
if "userPeerAliases" in hermes_host:
aliases_src = hermes_host.get("userPeerAliases")
aliases_from_root = False
else:
aliases_src = cfg.get("userPeerAliases")
aliases_from_root = aliases_src is not None
aliases = aliases_src if isinstance(aliases_src, dict) else {}
if "runtimePeerPrefix" in hermes_host:
prefix_src = hermes_host.get("runtimePeerPrefix")
prefix_from_root = False
else:
prefix_src = cfg.get("runtimePeerPrefix")
prefix_from_root = prefix_src is not None
prefix = str(prefix_src or "")
return pin, aliases, prefix, aliases_from_root, prefix_from_root
def _scrub_identity_mapping(hermes_host: dict) -> None:
"""Drop every peer-mapping key from the host block.
Called before the wizard writes a chosen shape so latent precedence
conflicts can't survive — e.g. a stray host ``pinUserPeer: false``
that would silently outrank a freshly written ``pinPeerName: true``
(host ``pinUserPeer`` is first in the resolver ladder).
"""
for key in _IDENTITY_MAPPING_KEYS:
hermes_host.pop(key, None)
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
suffix = f" [{default}]" if default else ""
sys.stdout.write(f" {label}{suffix}: ")
@ -447,9 +515,19 @@ def cmd_setup(args) -> None:
# shapes cover the realistic deployments; each writes a different
# combination of pinPeerName / userPeerAliases / runtimePeerPrefix.
# See plugins/memory/honcho/README.md for the resolver ladder.
current_pin = bool(hermes_host.get("pinPeerName", False))
current_aliases = hermes_host.get("userPeerAliases", {})
current_prefix = hermes_host.get("runtimePeerPrefix", "")
#
# Detection must mirror the gateway resolver: root-level config and
# ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)
# both affect effective routing, so reading host-only fields would
# mis-classify a profile that inherits its mapping from root or uses
# the newer canonical key.
(
current_pin,
current_aliases,
current_prefix,
aliases_from_root,
prefix_from_root,
) = _resolve_effective_identity_mapping(cfg, hermes_host)
if current_pin:
current_shape = "single"
@ -484,30 +562,52 @@ def cmd_setup(args) -> None:
elif confirm not in {"yes", "y"}:
new_shape = "skip"
# Each shape branch scrubs every peer-mapping key before writing its own,
# so a stale ``pinUserPeer`` left behind by an earlier setup run can't
# outrank the freshly written ``pinPeerName`` via host-level precedence.
if new_shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = True
hermes_host.pop("userPeerAliases", None)
hermes_host.pop("runtimePeerPrefix", None)
print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.")
elif new_shape == "multi":
# Preserve operator-curated, host-level aliases so multi → multi
# re-runs don't drop them. Root-sourced aliases are left to
# cascade naturally and are NOT copied down into the host.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
# Preserve any existing operator-curated aliases / prefix.
if "userPeerAliases" not in hermes_host:
hermes_host["userPeerAliases"] = {}
# Do NOT auto-write ``userPeerAliases: {}``: an empty host map
# would override any root-level ``userPeerAliases`` the operator
# set as a cross-host baseline, silently disabling those aliases.
# Absence is the right "no host opinion" signal.
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
if _new_prefix:
# Only write a host-level prefix when the operator typed one that
# diverges from the inherited root value; otherwise let the root
# cascade continue unmodified.
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
else:
hermes_host.pop("runtimePeerPrefix", None)
print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.")
elif new_shape == "hybrid":
# Hybrid encodes operator intent at the host level: collect existing
# entries (host or root) so the wizard never silently drops a known
# alias, then write the combined map. Materialising root entries
# into the host is the right move here — once the operator answers
# the alias prompts for a host, they're declaring "this host owns
# the mapping".
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
peer_target = hermes_host.get("peerName") or current_peer or "user"
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
@ -521,19 +621,13 @@ def cmd_setup(args) -> None:
existing_aliases[entered] = peer_target
if existing_aliases:
hermes_host["userPeerAliases"] = existing_aliases
elif "userPeerAliases" in hermes_host:
# No aliases entered and none pre-existing — leave the key absent.
if not hermes_host["userPeerAliases"]:
hermes_host.pop("userPeerAliases", None)
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
if _new_prefix:
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
else:
hermes_host.pop("runtimePeerPrefix", None)
print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.")
elif new_shape == "skip":
pass # leave config untouched