Files
hermes-agent/agent/memory_caller_scope.py

155 lines
7.4 KiB
Python

"""Which profile a memory-provider call or hook belongs to (#140058).
On a multiplexed gateway every served profile's turns run in one process. Each agent's manager owns
its own provider instance, bound by ``initialize_all`` to one profile home; the provider-side half of
the rule is the multiplexing contract in ``agent/memory_provider.py``. The manager's half lives here:
- a tool call or lifecycle hook runs only when the caller explicitly names the profile the manager was
initialized for (the context's HERMES_HOME override, or the home its secret scope was built for).
On a multiplexed process a caller that names no profile is refused, never resolved to the process's
own profile; a single-profile process falls back to its home as before;
- one provider instance serves one profile: a plugin that hands several agents the same instance is
refused for every profile after the first, instead of being rebound to it.
Refusals log the check's code and both homes (errors.log on every host, gateway.log on a gateway); the
model/chat only ever sees a generic refusal.
"""
from __future__ import annotations
import logging
import threading
import weakref
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
CALLER_PROFILE_REFUSAL = "memory_caller_profile_unresolved"
SHARED_INSTANCE_REFUSAL = "memory_provider_instance_shared"
# id(provider) -> (weak reference to that very instance, profile home key). Keyed by identity, never
# by the provider's own __hash__/__eq__: an unhashable or value-equal provider is still tracked.
_claims: dict[int, tuple[weakref.ref, str]] = {}
_claims_lock = threading.RLock() # reentrant: a weakref callback can fire from GC inside the lock
def refused_tool_message(tool_name: str) -> str:
"""What the model/chat sees for a refused memory tool call: no check name, no home paths."""
return f"Memory tool '{tool_name}' refused. Ask the operator to check `hermes logs errors`."
def _home_key(home: Any) -> Optional[str]:
if not home:
return None
from hermes_constants import hermes_home_key
try:
return hermes_home_key(home)
except (OSError, RuntimeError, ValueError): # unresolvable path: no key, so the call is refused
return None
def _explicit_caller_home() -> Optional[str]:
"""The profile the caller's context names: its HERMES_HOME override, else the home its secret
scope was built for. None when the context names no profile (``get_hermes_home()`` would only fall
back to the process home, which proves nothing on a multiplexed process)."""
from agent.secret_scope import current_secret_scope_home
from hermes_constants import get_hermes_home_override
return get_hermes_home_override() or current_secret_scope_home()
def _refuse(label: str, reason: str, caller_home: Any, bound_home: Any, multiplexed: bool) -> None:
logger.warning("Memory %s refused (%s): %s; caller profile home %r, provider bound to %r, multiplexed=%s",
label, CALLER_PROFILE_REFUSAL, reason, caller_home, bound_home, multiplexed)
def bound_scope(hermes_home: Any, agent_identity: Any = None) -> dict[str, Any]:
"""What ``initialize_all`` records for ``caller_scope``: the bound home's key is resolved once
here, so each call only resolves the caller's."""
return {"hermes_home": hermes_home, "agent_identity": agent_identity, "home_key": _home_key(hermes_home)}
def caller_scope(bound: Optional[dict[str, Any]], label: str) -> Optional[dict[str, Any]]:
"""Provider kwargs naming the calling profile, or None when the call/hook must be refused.
``bound`` is what ``initialize_all`` recorded (``bound_scope``); ``label`` names the call in the
log (``tool <name>`` / ``hook <name>``)."""
from agent.secret_scope import is_multiplex_active
bound = bound or {}
bound_home = bound.get("hermes_home")
multiplexed = is_multiplex_active()
caller_home = _explicit_caller_home()
if caller_home is None:
if multiplexed:
_refuse(label, "the caller names no profile on a process serving several", None, bound_home, multiplexed)
return None
from hermes_constants import get_hermes_home
caller_home = str(get_hermes_home()) # single-profile process: its own home is the only one
if not bound_home:
if multiplexed:
_refuse(label, "the memory manager recorded no profile", caller_home, None, multiplexed)
return None
return {"hermes_home": str(caller_home)}
caller_key = _home_key(caller_home)
if caller_key is None or caller_key != (bound.get("home_key") or _home_key(bound_home)):
_refuse(label, "the caller's profile is not the one its provider was initialized for",
caller_home, bound_home, multiplexed)
return None
scope: dict[str, Any] = {"hermes_home": str(bound_home)}
if bound.get("agent_identity"):
scope["agent_identity"] = bound["agent_identity"]
return scope
def _forget(pid: int, ref: weakref.ref) -> None:
# Only this dead instance's own entry: once it is collected its id() can be reused by a new
# provider whose claim already replaced the entry, and that claim must survive.
with _claims_lock:
if _claims.get(pid, (None,))[0] is ref:
del _claims[pid]
def claim_instance(provider: Any, home: Any) -> bool:
"""Record that ``provider`` (the instance) is bound to ``home``. False on a multiplexed process
when another profile already bound this same instance (initializing it again would rebind the
other profile's store), or when the instance can't be tracked at all (no weak reference)."""
from agent.secret_scope import is_multiplex_active
key = _home_key(home) or str(home)
multiplexed = is_multiplex_active()
pid = id(provider)
with _claims_lock:
entry = _claims.get(pid)
owner = entry[1] if entry is not None and entry[0]() is provider else None
if owner is not None and owner != key and multiplexed:
logger.warning("Memory provider '%s' refused for profile home %r (%s): this same provider instance "
"is already bound to %r; a provider must give every agent its own instance",
getattr(provider, "name", "?"), str(home), SHARED_INSTANCE_REFUSAL, owner)
return False
try:
ref = weakref.ref(provider, lambda dead, pid=pid: _forget(pid, dead))
except TypeError:
if multiplexed:
logger.warning("Memory provider '%s' refused for profile home %r (%s): the instance can't be "
"tracked (no weak reference support), so sharing it can't be ruled out",
getattr(provider, "name", "?"), str(home), SHARED_INSTANCE_REFUSAL)
return False
return True
_claims[pid] = (ref, key)
return True
def in_owner_home(bound: Optional[dict[str, Any]], fn: Callable[[], Any]) -> Callable[[], Any]:
"""``fn`` run under the owning profile's HERMES_HOME override, set explicitly: a background
worker must not depend on the submitter's context having carried it."""
home = (bound or {}).get("hermes_home")
if not home:
return fn
def _run() -> Any:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(str(home))
try:
return fn()
finally:
reset_hermes_home_override(token)
return _run