From 571a4e0c7022b3f3aef382d76a85c39e56416ea4 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:55:04 +0530 Subject: [PATCH] fix(gateway): agent-cache eviction commits memory under the OWNING profile, not the requesting one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- gateway/run_agent_cache.py | 43 +++++++----- .../test_agent_cache_release_profile_scope.py | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index be24d79f3d..3fe4809e43 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -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::...``) 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: diff --git a/tests/gateway/test_agent_cache_release_profile_scope.py b/tests/gateway/test_agent_cache_release_profile_scope.py index c02e830258..c419d0e0e4 100644 --- a/tests/gateway/test_agent_cache_release_profile_scope.py +++ b/tests/gateway/test_agent_cache_release_profile_scope.py @@ -65,3 +65,72 @@ def test_in_turn_cap_eviction_keeps_the_callers_scope(tmp_path, monkeypatch): secret_scope.reset_secret_scope(token) secret_scope.set_multiplex_active(False) assert seen["scope"] == {"MARKER": "turn-scope"} + + +def test_in_turn_cap_eviction_of_another_profiles_agent_enters_the_owners_scope(tmp_path, monkeypatch): + """The LRU cap is enforced inside the REQUESTING turn (profile A's scope), and the agent it + evicts may be profile B's. B's end-of-session commit must run under B, not under A.""" + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + default_home = tmp_path / ".hermes" + prof_a, prof_b = default_home / "profiles" / "a", default_home / "profiles" / "b" + prof_a.mkdir(parents=True), prof_b.mkdir(parents=True) + (prof_b / ".env").write_text("HINDSIGHT_LLM_API_KEY=key-of-b\n") + monkeypatch.setenv("HERMES_HOME", str(default_home)) + secret_scope.set_multiplex_active(True) + home_token = set_hermes_home_override(str(prof_a)) + scope_token = secret_scope.set_secret_scope({"HINDSIGHT_LLM_API_KEY": "key-of-a"}) + try: + seen = _seen_after_release(_runner({"agent:b:telegram:dm:1": prof_b}), "agent:b:telegram:dm:1") + finally: + secret_scope.reset_secret_scope(scope_token) + reset_hermes_home_override(home_token) + secret_scope.set_multiplex_active(False) + assert seen["home"] == prof_b + assert seen["scope"].get("HINDSIGHT_LLM_API_KEY") == "key-of-b" + + +def test_in_turn_cap_eviction_of_a_default_profile_agent_leaves_the_secondarys_scope(tmp_path, monkeypatch): + """Inverse: secondary A's turn evicts a default-profile session (``agent:main:`` — no named + owner in the key). The commit runs under the default home, not under A.""" + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + default_home = tmp_path / ".hermes" + prof_a = default_home / "profiles" / "a" + prof_a.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + secret_scope.set_multiplex_active(True) + home_token = set_hermes_home_override(str(prof_a)) + scope_token = secret_scope.set_secret_scope({"MARKER": "profile-a"}) + try: + seen = _seen_after_release(_runner({}), "agent:main:telegram:dm:9") + finally: + secret_scope.reset_secret_scope(scope_token) + reset_hermes_home_override(home_token) + secret_scope.set_multiplex_active(False) + assert seen["home"] == default_home + assert seen["scope"] is not None and seen["scope"].get("MARKER") is None + + +def test_default_profile_owner_is_the_root_even_when_launched_under_a_named_profile(tmp_path, monkeypatch): + """``hermes -p x gateway`` sets HERMES_HOME to x's home and serves the default profile as a + secondary. An ``agent:main:`` session still belongs to the default profile at the ROOT, not to + the launch profile x — otherwise x's turn would commit default's transcript under x.""" + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + root = tmp_path / ".hermes" + prof_x = root / "profiles" / "x" + prof_x.mkdir(parents=True) + (root / ".env").write_text("MARKER=default-root\n") + monkeypatch.setenv("HERMES_HOME", str(prof_x)) # launched under x + secret_scope.set_multiplex_active(True) + home_token = set_hermes_home_override(str(prof_x)) + scope_token = secret_scope.set_secret_scope({"MARKER": "profile-x"}) + try: + seen = _seen_after_release(_runner({}), "agent:main:telegram:dm:3") + finally: + secret_scope.reset_secret_scope(scope_token) + reset_hermes_home_override(home_token) + secret_scope.set_multiplex_active(False) + assert seen["home"] == root + assert seen["scope"].get("MARKER") == "default-root"