mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
155 lines
7.4 KiB
Python
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
|