fix(gateway): agent-cache eviction commits memory under the OWNING profile, not the requesting one

#108319 made the release thread carry the caller's context and enter the owner's profile
scope only when no scope was present. But the LRU-cap eviction runs inside the REQUESTING
turn (run_turn_runner: _enforce_agent_cache_cap / _release_evicted_agent), and the LRU
agent it evicts may belong to another profile — so with profile A's scope active, profile
B's end-of-session memory commit ran under A's scope and A's home: B's transcript extracted
into A's provider namespace with A's credentials. Before #108319 the same commit ran under
the launch profile; the fix moved the leak, it did not close it.

_run_release_in_profile_scope now resolves the owner from the session key first — a named
profile's home, else the DEFAULT profile at the root Hermes dir (agent:main: keys), which
is not the launch profile when the gateway runs under `hermes -p x` — and enters that
profile's scope whenever the current one is not already the owner's (compared via
hermes_home_key). Same-profile in-turn evictions and the unscoped housekeeping sweep behave
as before. A failed owner lookup is logged at WARNING instead of silently swallowed, since
it reassigns the commit to the default profile.

Three invariant tests: A's turn evicting B's agent commits under B; a secondary's turn
evicting a default-profile agent commits under the default home; the same with the gateway
launched under a named profile still commits under the root. All red on main.
This commit is contained in:
kshitijk4poor
2026-09-15 17:55:04 +05:30
committed by kshitij
parent d735097376
commit 571a4e0c70
2 changed files with 95 additions and 17 deletions
+26 -17
View File
@@ -9,6 +9,7 @@ import logging
import threading
import time
from contextlib import nullcontext, suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from agent.interrupt_compat import _accepts_keyword
@@ -691,24 +692,32 @@ class GatewayAgentCacheMixin:
ctx.run(self._run_release_in_profile_scope, target, args, session_key)
def _run_release_in_profile_scope(self, target, args: tuple, session_key: Optional[str]) -> None:
"""Call ``target(*args)`` under the profile that owns ``session_key``. Threads start with an
EMPTY context, so a bare thread would commit end-of-session memory (provider ``on_session_end``
reads credentials/home at call time) under the LAUNCH profile — lost memories or a secondary's
transcript extracted into the default profile's provider namespace. In-turn callers already
carry the scope (``copy_context`` preserves it); the unscoped housekeeping sweep resolves the
owner from the session key (``agent:<profile>:...``) and enters that profile's scope."""
"""Call ``target(*args)`` under the profile that OWNS ``session_key``.
Threads start with an EMPTY context, so a bare thread would commit end-of-session memory
(provider ``on_session_end`` reads credentials/home at call time) under the launch profile.
And the LRU-cap eviction runs inside the REQUESTING turn, whose agent may belong to another
profile — so "some scope is present" is not enough either. The owner comes from the session
key: a named profile's home, else the DEFAULT profile (``agent:main:`` keys), which is the
root Hermes dir even when the gateway was launched under a named profile. Its scope is
entered unless the current one already is the owner's."""
from agent.secret_scope import current_secret_scope, is_multiplex_active
if current_secret_scope() is not None or not is_multiplex_active():
target(*args)
return
from gateway.run import _profile_runtime_scope
from hermes_constants import get_hermes_home
home = None
store = getattr(self, "session_store", None)
if session_key and store is not None:
with suppress(Exception):
home = store._profile_home_for_key(session_key)
with _profile_runtime_scope(home or get_hermes_home()):
scope = nullcontext()
if is_multiplex_active():
from gateway.run import _profile_runtime_scope
from hermes_constants import get_default_hermes_root, get_hermes_home, hermes_home_key
owner = None
store = getattr(self, "session_store", None)
if session_key and store is not None:
try:
owner = store._profile_home_for_key(session_key)
except Exception:
logger.warning("Could not resolve the owning profile for %s; releasing under the default profile",
session_key, exc_info=True)
owner_home = Path(owner) if owner else get_default_hermes_root()
if current_secret_scope() is None or hermes_home_key(get_hermes_home()) != hermes_home_key(owner_home):
scope = _profile_runtime_scope(owner_home)
with scope:
target(*args)
def _commit_memory_before_soft_evict(self, agent: Any, key: str) -> None: