beab8b6f27
`_flush_session` discarded the observation flags when it rebuilt an evicted SDK session, and the cached path returned none, so recall fell back to the config snapshot. Both paths now return and store the flags. A deferred `save()` on a session the cap evicted puts it back in the cache, or flushes it inline when a newer object owns the key. `save()` and `stop_async_writer()` share the writer lock, and the writer drains its queue after the join, so a put that raced shutdown is written. The trim after a flush runs under the cache lock. The shutdown join takes the remaining budget instead of a fixed ten seconds. The injection audit file is created owner-only, and `logging: "false"` reads as off. The desktop passes `<provider>:<user id>` so a basic-auth alice and an OIDC alice are two peers. When a gateway platform supplies no user id, the peer notice and tool error no longer recommend peerName, which would merge every user of that gateway onto one peer. README documents `injection.sessionStart`, `logging`, and what a dashboard login does to peer resolution.
172 lines
8.7 KiB
Python
172 lines
8.7 KiB
Python
"""Peer-ID resolution for HonchoSessionManager (config aliases, runtime identities, fallbacks)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from plugins.memory.honcho.session import HonchoSession
|
|
|
|
logger = logging.getLogger("plugins.memory.honcho.session")
|
|
|
|
_PEER_ID_HASH_ESCALATION_LENGTHS = (8, 12, 16, 24, 32, 64)
|
|
# Author ids the bot-mode dispatcher assigns to other Hermes profiles (tools/bot_relay.py).
|
|
BOT_AUTHOR_PREFIX = "bot:"
|
|
|
|
|
|
def sanitize_peer_id(id_str: str) -> str:
|
|
"""Sanitize an ID to match Honcho's pattern: ^[a-zA-Z0-9_-]+"""
|
|
return re.sub(r'[^a-zA-Z0-9_-]', '-', id_str)
|
|
|
|
|
|
def assistant_peer_id_for(config: Any) -> str:
|
|
"""The agent's own peer ID from ``aiPeer``, as the session builder derives it."""
|
|
return sanitize_peer_id(getattr(config, "ai_peer", None) or "hermes-assistant")
|
|
|
|
|
|
class HonchoPeerUnresolvedError(RuntimeError):
|
|
"""No user peer can be named: the transport supplied no runtime identity and honcho.json
|
|
declares no peerName. Raised instead of minting a session-derived peer nobody declared."""
|
|
|
|
|
|
class SessionPeersMixin:
|
|
"""Resolve user/assistant/observer peer IDs. Reads ``self._config`` and runtime identities only."""
|
|
|
|
def _sanitize_id(self, id_str: str) -> str:
|
|
return sanitize_peer_id(id_str)
|
|
|
|
def _cfg(self, name: str, default: Any = None) -> Any:
|
|
return getattr(self._config, name, default) if self._config is not None else default
|
|
|
|
def _runtime_user_ids(self) -> list[str]:
|
|
"""Runtime identity candidates in lookup order (deduped, blanks dropped)."""
|
|
candidates = [str(v).strip() for v in (self._runtime_user_peer_name, self._runtime_user_peer_name_alt) if v is not None]
|
|
return list(dict.fromkeys(c for c in candidates if c))
|
|
|
|
def _peer_aliases(self) -> dict:
|
|
aliases = self._cfg("user_peer_aliases", {})
|
|
return aliases if isinstance(aliases, dict) else {}
|
|
|
|
def _explicit_user_peer_ids(self) -> set[str]:
|
|
"""Return sanitized user peer IDs that came from explicit config."""
|
|
explicit_ids = {self._sanitize_id(alias.strip()) for alias in self._peer_aliases().values()
|
|
if isinstance(alias, str) and alias.strip()}
|
|
owner = self._declared_owner_peer_id()
|
|
if owner:
|
|
explicit_ids.add(owner)
|
|
return explicit_ids
|
|
|
|
def _generated_runtime_peer_id(self, prefix: str, runtime_id: str, reserved: set[str] | None = None) -> str:
|
|
"""Stable peer ID for an unknown prefixed runtime user; a hash suffix is added when
|
|
sanitizing changed the ID or it collides with an explicitly configured or ``reserved`` peer."""
|
|
raw_peer_id = f"{prefix}{runtime_id}"
|
|
sanitized_peer_id = self._sanitize_id(raw_peer_id)
|
|
explicit_ids = self._explicit_user_peer_ids() | (reserved or set())
|
|
if sanitized_peer_id == raw_peer_id and sanitized_peer_id not in explicit_ids:
|
|
return sanitized_peer_id
|
|
digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()
|
|
for hash_len in (*_PEER_ID_HASH_ESCALATION_LENGTHS, len(digest)):
|
|
candidate = f"{sanitized_peer_id}-{digest[:hash_len]}"
|
|
if candidate not in explicit_ids or hash_len == len(digest):
|
|
return candidate
|
|
|
|
def _declared_owner_peer_id(self) -> str | None:
|
|
"""Sanitized ``peerName`` (the install owner), or None when none is declared."""
|
|
peer_name = str(self._cfg("peer_name") or "").strip()
|
|
return self._sanitize_id(peer_name) if peer_name else None
|
|
|
|
def _resolve_user_peer_id(self, key: str) -> str:
|
|
"""Honcho user peer ID for this manager/session. Order: pinned peerName -> alias of a
|
|
runtime identity -> (prefixed) runtime identity -> configured peerName. Raises
|
|
HonchoPeerUnresolvedError when none applies: every peer must be one the operator declared
|
|
or one the transport supplied, never a name derived from the session key."""
|
|
peer_name = str(self._cfg("peer_name") or "").strip()
|
|
if peer_name and self._cfg("pin_peer_name", False) is True:
|
|
return self._sanitize_id(peer_name)
|
|
|
|
runtime_ids = self._runtime_user_ids()
|
|
if runtime_ids:
|
|
aliases = self._peer_aliases()
|
|
for runtime_id in runtime_ids:
|
|
alias = aliases.get(runtime_id)
|
|
if isinstance(alias, str) and alias.strip():
|
|
return self._sanitize_id(alias.strip())
|
|
prefix = self._cfg("runtime_peer_prefix", "")
|
|
prefix = prefix.strip() if isinstance(prefix, str) else ""
|
|
if prefix:
|
|
return self._generated_runtime_peer_id(prefix, runtime_ids[0])
|
|
return self._sanitize_id(runtime_ids[0])
|
|
|
|
if peer_name:
|
|
return self._sanitize_id(peer_name)
|
|
raise HonchoPeerUnresolvedError(
|
|
f"Honcho has no user peer for session '{key}': the transport supplied no user identity and "
|
|
"honcho.json declares no peerName.")
|
|
|
|
def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str:
|
|
"""Resolve a peer alias ('user'/'ai') or explicit peer ID to a concrete, non-empty peer ID."""
|
|
normalized = self._sanitize_id((peer or "user").strip() or "user")
|
|
return {"user": session.user_peer_id, "ai": session.assistant_peer_id}.get(normalized, normalized)
|
|
|
|
def _resolve_observer_target(self, session: HonchoSession, peer: str | None) -> tuple[str, str | None]:
|
|
"""Resolve (observer, target) peer IDs for context/search/profile queries."""
|
|
target_peer_id = self._resolve_peer_id(session, peer)
|
|
if target_peer_id == session.assistant_peer_id:
|
|
return session.assistant_peer_id, session.assistant_peer_id
|
|
if self._ai_observes_others(session):
|
|
return session.assistant_peer_id, target_peer_id
|
|
return target_peer_id, None
|
|
|
|
def _session_human_peer_ids(self, key: str | None) -> set[str]:
|
|
"""Peer IDs the session's human writes under: each runtime id and the peer resolved for ``key``."""
|
|
ids = {self._sanitize_id(runtime_id) for runtime_id in self._runtime_user_ids()}
|
|
if key:
|
|
ids.add(self._resolve_user_peer_id(key))
|
|
return ids
|
|
|
|
def _peer_id_for_runtime_id(self, runtime_id: str, key: str | None = None) -> str:
|
|
"""Honcho peer ID for one runtime identity: alias first, then prefix, as at session init.
|
|
|
|
A ``bot:`` author is keyed by its full id (``bot:<profile>`` or ``bot:<connection>/<profile>``) and,
|
|
without an alias, its peer is derived from everything after ``bot:`` with the same digest suffix
|
|
rule as prefixed runtime users, so it never lands on ``peerName``, an alias target, or the peer
|
|
the session's human writes under."""
|
|
alias = self._peer_aliases().get(runtime_id)
|
|
if isinstance(alias, str) and alias.strip():
|
|
return self._sanitize_id(alias.strip())
|
|
if runtime_id.startswith(BOT_AUTHOR_PREFIX):
|
|
ident = runtime_id[len(BOT_AUTHOR_PREFIX):].strip()
|
|
return self._generated_runtime_peer_id("", ident or runtime_id, reserved=self._session_human_peer_ids(key))
|
|
prefix = self._cfg("runtime_peer_prefix", "")
|
|
prefix = prefix.strip() if isinstance(prefix, str) else ""
|
|
return self._generated_runtime_peer_id(prefix, runtime_id) if prefix else self._sanitize_id(runtime_id)
|
|
|
|
def assistant_peer_id(self) -> str:
|
|
"""This agent's own peer ID, as the session builder derives it from ``aiPeer``."""
|
|
return assistant_peer_id_for(self._config)
|
|
|
|
def resolve_author_peer_id(
|
|
self, key: str, author_id: str | None, author_name: str | None = None, *, is_bot: bool = False,
|
|
) -> str | None:
|
|
"""Peer ID for the turn's author. None keeps the session's own peer.
|
|
|
|
A bot author (``is_bot`` or a ``bot:`` id) always gets its own peer: the pin only collapses the
|
|
operator's accounts. ``author_name`` never becomes a peer ID because display names are
|
|
attacker-influenceable."""
|
|
runtime_id = str(author_id).strip() if author_id else ""
|
|
if not runtime_id:
|
|
return None
|
|
if is_bot or runtime_id.startswith(BOT_AUTHOR_PREFIX):
|
|
return self._peer_id_for_runtime_id(runtime_id, key)
|
|
if self._config is not None and bool(getattr(self._config, "peer_name", None)) \
|
|
and getattr(self._config, "pin_peer_name", False) is True:
|
|
return None
|
|
# Either runtime id (Telegram UID or username) names the session's participant.
|
|
if runtime_id in self._runtime_user_ids():
|
|
return None
|
|
peer_id = self._peer_id_for_runtime_id(runtime_id)
|
|
return None if peer_id == self._resolve_user_peer_id(key) else peer_id
|