Files
hermes-agent/gateway/session_identity.py
teknium1 d899d890f8 fix(gateway): a restored lane whose receiving bot is offline delivers nowhere
Composing PR-4 (intake/delivery split) with PR-5 (persisted transport_profile): the delivery
fallback to the runtime profile's unique adapter is only for sources with NO identity. A pinned
identity names the receiving bot; if that bot has no adapter it is offline and the lane fails
closed, never answering from the runtime profile's bot. Also: tests and docs reference the split
helper, not the removed _adapter_for_source.
2026-09-19 02:28:50 -07:00

274 lines
14 KiB
Python

"""One frozen routing identity per inbound gateway event.
A multiplexed gateway answers three questions about every event, and until now answered them in
three places that only agreed because they read the same fallback chain: WHICH bot received it
(``_transport_owner``), WHO may admit it (``_authorization_home_for_source``) and WHERE the turn
runs (``_resolve_profile_home_for_source`` / ``_session_key_profile``). :func:`resolve_identity`
answers all three once and pins the result on the source as a wire-invisible dynamic attribute
(like ``_transport_adapter_ref``); the existing helpers read it when present and keep their
fallback chain when absent, so a source built outside the runner still resolves as before.
``SessionSource.profile`` stays the serialized runtime profile — ``None`` on the wire means the
receiving bot's own profile — so nothing here changes the wire format or any historical
``agent:main`` key.
"""
from __future__ import annotations
import dataclasses
import logging
from contextlib import suppress
import weakref
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from gateway.session import SessionSource
logger = logging.getLogger(__name__)
_IDENTITY_ATTR = "_identity"
# Wire-invisible provenance copied alongside the identity when a source is duplicated.
_PROVENANCE_ATTRS = ("_transport_adapter_ref", "_authorization_profile_home", _IDENTITY_ATTR)
class IdentityUnresolved(RuntimeError):
"""Under multiplexing the event's runtime profile could not be established (an explicit
``profile_routes`` entry targets a profile this gateway does not serve). Callers drop the
event; it must never fall through to the default profile."""
@dataclass(frozen=True)
class RoutingIdentity:
"""Everything a turn needs to know about who it is, resolved once at ingress.
``transport_profile`` owns the receiving adapter (its credential and allowlist);
``runtime_profile`` is the profile that executes the turn — the same name unless a
``profile_routes`` entry re-homed the event. Both are explicit (``"default"`` is spelled out);
``None`` never means default here. ``multiplexed`` is False for a standalone gateway, whose
keys stay in the legacy ``agent:main`` namespace whatever profile it was launched with.
"""
transport_profile: str
runtime_profile: str
authorization_home: Path
runtime_home: Path
multiplexed: bool = True
# Receiving adapter; None for restored/synthetic sources (no live provenance → fail closed).
# Provenance, not identity: two events from the same bot share one identity.
transport: Optional[weakref.ref] = field(default=None, compare=False, hash=False)
# True when nothing named the receiving bot (no live adapter, no persisted transport_profile,
# no explicit hint) and ``transport_profile`` is the primary by default. A hand-built or
# pre-column source. Delivery may still fall back to the runtime profile's unique adapter for
# these; an identity whose transport is KNOWN (live or restored) never does.
transport_inferred: bool = field(default=False, compare=False, hash=False)
@property
def namespace(self) -> str:
"""``agent:<ns>`` prefix for this identity's session keys — byte-identical to
:func:`gateway.session._session_key_namespace` for every historical key."""
from gateway.session import _session_key_namespace
return _session_key_namespace(self.session_key_profile)
@property
def store_path(self) -> Path:
return self.runtime_home / "state.db"
@property
def session_key_profile(self) -> Optional[str]:
"""The ``profile=`` argument :func:`gateway.session.build_session_key` expects for this
identity: the runtime profile under multiplexing, else ``None`` (legacy namespace)."""
return self.runtime_profile if self.multiplexed else None
def adapter(self) -> Any:
"""The live receiving adapter, or None when it is gone or was never known."""
return self.transport() if self.transport is not None else None
def identity_of(source: Any) -> Optional[RoutingIdentity]:
"""The identity pinned on *source* by :func:`resolve_identity`, if any."""
identity = getattr(source, _IDENTITY_ATTR, None)
return identity if isinstance(identity, RoutingIdentity) else None
def clear_identity(source: Any) -> None:
"""Drop the pinned identity AND the routed runtime profile so the next :func:`canonical_identity`
re-routes from scratch — for sources a caller reuses across events whose routing may differ (a
guild's cached voice source is shared by every speaker, and ``profile_routes[].user_id`` routes
per speaker). ``source.profile`` is the previous event's routing *result*; left in place it
short-circuits the route and the new speaker runs as the old one."""
for attr in (_IDENTITY_ATTR, "profile_route_rejected", "_authorization_profile_home"):
with suppress(AttributeError):
delattr(source, attr)
with suppress(AttributeError):
source.profile = None
def transport_profile_of(source: Any) -> Optional[str]:
"""The receiving bot's profile to persist alongside a routing entry (``SessionEntry.transport_profile``);
None outside multiplexing or when nothing resolved the source (an unknown transport is never guessed)."""
identity = identity_of(source)
return identity.transport_profile if identity is not None and identity.multiplexed else None
def replace_source(source: "SessionSource", **changes: Any) -> "SessionSource":
""":func:`dataclasses.replace` that keeps the wire-invisible provenance (transport ref,
authorization home, identity). A plain ``replace`` silently produces a source the runner
can only route through heuristics."""
copied = dataclasses.replace(source, **changes)
for name in _PROVENANCE_ATTRS:
value = getattr(source, name, None)
if value is not None:
setattr(copied, name, value)
return copied
def _name(value: Any) -> Optional[str]:
text = value.strip() if isinstance(value, str) else ""
return text or None
def canonical_identity(
source: "SessionSource", *, runner: Any, adapter: Any = None,
transport_profile: Optional[str] = None, primary_home: Optional[Path] = None,
) -> Optional[RoutingIdentity]:
"""The identity already pinned on *source*, else :func:`resolve_identity` — the one call every
ingress path makes FIRST, before any key is derived. ``None`` = unresolved under multiplexing
(``source.profile_route_rejected`` is set): the caller drops the event and says so once; it
must never fall through to ``agent:main``."""
identity = identity_of(source)
if identity is not None:
return identity
try:
return resolve_identity(
source, runner=runner, adapter=adapter, transport_profile=transport_profile,
primary_home=primary_home)
except IdentityUnresolved as exc:
logger.debug("identity unresolved: %s", exc)
return None
def restore_identity(
source: "SessionSource", *, runner: Any, transport_profile: Optional[str],
) -> Optional[RoutingIdentity]:
"""Pin the identity of a source rebuilt from durable state (``SessionEntry.origin``, a
``sessions`` row, a cached copy) — no live adapter, so ``transport=None``: the restored row of the
transport matrix, where delivery goes through the persisted transport owner or fails closed.
*transport_profile* is what the routing index persisted at ingress (``SessionEntry.transport_profile``);
``None`` = a row written before the column existed, whose transport is unknown → nothing is pinned
and the legacy heuristics (``_is_shared_bot_satellite``) keep deciding. Standalone gateways have
nothing to restore (one bot, one home).
"""
transport_name = _name(transport_profile)
if transport_name is None:
return None
if not bool(getattr(getattr(runner, "config", None), "multiplex_profiles", False)):
return None
existing = identity_of(source)
if existing is not None:
return existing
from hermes_cli.profiles import get_profile_dir
from hermes_constants import get_process_hermes_home
primary_profile = _name(getattr(runner, "_primary_profile_name", None)) or "default"
runtime_name = _name(getattr(source, "profile", None)) or primary_profile
authorization_home = (
Path(get_process_hermes_home()) if transport_name == primary_profile
else get_profile_dir(transport_name))
runtime_home = (
authorization_home if runtime_name == transport_name
else Path(runner._resolve_profile_home_for_source(source)))
source._authorization_profile_home = authorization_home
identity = RoutingIdentity(
transport_profile=transport_name, runtime_profile=runtime_name,
authorization_home=authorization_home, runtime_home=runtime_home,
multiplexed=True, transport=None)
setattr(source, _IDENTITY_ATTR, identity)
return identity
def resolve_identity(
source: "SessionSource", *, runner: Any, adapter: Any = None,
transport_profile: Optional[str] = None, primary_home: Optional[Path] = None,
) -> RoutingIdentity:
"""Resolve and pin the :class:`RoutingIdentity` of an inbound *source*.
*adapter* is the receiving adapter when the caller holds it; otherwise the source's own
transport provenance is consulted. *transport_profile* names the receiving bot's owning
profile when the caller knows it by construction (the runner's per-profile handlers);
``None`` = derive it from the adapter registry, primary when unknown. *primary_home* is the
primary bot's home for authorization (default: the process home, never a per-turn override).
Stamps ``source.profile`` the way the ingress handlers always did (routed name, else a
secondary's own name; ``None`` stays ``None`` for the primary so the wire is unchanged) and
``_authorization_profile_home`` for the existing authorization readers.
Raises :class:`IdentityUnresolved` under multiplexing when the route is rejected.
"""
from gateway.profile_routing import ProfileRouteRejected
from hermes_constants import get_hermes_home, get_process_hermes_home
multiplexed = bool(getattr(getattr(runner, "config", None), "multiplex_profiles", False))
primary_profile = _name(getattr(runner, "_primary_profile_name", None))
if primary_profile is None:
active = getattr(runner, "_active_profile_name", None)
primary_profile = (_name(active()) if callable(active) else None) or "default"
platform = getattr(source, "platform", None)
owner_profile: Optional[str] = None
if adapter is None:
owner = runner._transport_owner(source)
if owner is not None:
adapter, owner_profile = owner
else:
if getattr(source, "_transport_adapter_ref", None) is None:
source._transport_adapter_ref = weakref.ref(adapter)
_registered, owner_profile = runner._owning_profile(adapter, platform)
transport_name = _name(transport_profile) or _name(owner_profile) or primary_profile
transport_inferred = adapter is None and _name(transport_profile) is None and _name(owner_profile) is None
transport_ref = weakref.ref(adapter) if adapter is not None else None
if not multiplexed:
home = Path(get_hermes_home())
identity = RoutingIdentity(
transport_profile=primary_profile, runtime_profile=primary_profile,
authorization_home=home, runtime_home=home, multiplexed=False, transport=transport_ref)
setattr(source, _IDENTITY_ATTR, identity)
return identity
if transport_name == primary_profile:
authorization_home = Path(primary_home) if primary_home is not None else Path(get_process_hermes_home())
else:
from hermes_cli.profiles import get_profile_dir
authorization_home = get_profile_dir(transport_name)
source._authorization_profile_home = authorization_home
where = f"{getattr(platform, 'value', platform)}/{getattr(source, 'chat_id', '')}"
if getattr(source, "profile_route_rejected", False) is True:
raise IdentityUnresolved(f"{where}: profile route rejected")
if _name(getattr(source, "profile", None)) is None:
adapter_profile = None if transport_name == primary_profile else transport_name
try:
# The primary keeps the historical one-argument call (its adapter profile is None).
routed = (
runner._profile_name_for_source(source) if adapter_profile is None
else runner._profile_name_for_source(source, adapter_profile=adapter_profile))
except ProfileRouteRejected as exc:
source.profile_route_rejected = True
raise IdentityUnresolved(f"{where}: {exc}") from exc
source.profile = routed or adapter_profile
runtime_name = _name(source.profile) or primary_profile
# A routed runtime goes through the runner's resolver (missing-profile fallback + warning);
# a bot serving its own profile runs where it authorizes.
runtime_home = (
authorization_home if runtime_name == transport_name
else Path(runner._resolve_profile_home_for_source(source)))
identity = RoutingIdentity(
transport_profile=transport_name, runtime_profile=runtime_name,
authorization_home=authorization_home, runtime_home=runtime_home,
multiplexed=True, transport=transport_ref, transport_inferred=transport_inferred)
setattr(source, _IDENTITY_ATTR, identity)
return identity