From f665e507381969ae3ddca8fd646b79b53495b892 Mon Sep 17 00:00:00 2001 From: qxxaa Date: Sun, 10 May 2026 09:57:45 +0100 Subject: [PATCH] fix: stamp profile on continuation session after context compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When context compression fires, the agent rotates to a new session_id. The compression migration block correctly migrates the session lock, SESSION_AGENT_CACHE, SESSIONS dict, and the session file rename, but does not ensure s.profile is set on the continuation session. On the next request, _run_agent_streaming resolves the profile via: get_hermes_home_for_profile(getattr(s, 'profile', None)) With s.profile == None this falls back to the default profile's HERMES_HOME. Memory tool calls then read and write the wrong profile's MEMORY.md — confirmed by investigation: session 0dfefb (continuation after compression from a troubleshooting profile session) read memory at 16% / 1,184 chars with 4 entries, while the troubleshooting profile's actual state was 72-77% / 5,000+ chars. That reading could only come from the default profile's bank. Subsequent replace operations failed because the target entries existed only in the troubleshooting profile. There are two failure paths: 1. In-memory: if s.profile was None from the start (legacy session or one created before this fix), the continuation session object carries null through the current request. 2. Persistence: s.save() persists "profile": null to the continuation session's JSON file (profile is in METADATA_FIELDS, models.py ~408). On the next request, Session.load(new_sid) reads it back as null and get_hermes_home_for_profile(None) falls back to the default profile. Fix: capture _resolved_profile_name at request entry (~line 2019), immediately after profile home resolution. This is the only point where profile context is reliable: s.profile if already set, otherwise get_active_profile_name() — which at that point reads thread-local storage (_tls.profile) correctly set by the HTTP handler thread via set_request_profile(). Calling get_active_profile_name() at compression time instead would be unsafe: the streaming thread is a separate threading.Thread, does not inherit TLS, and the call would fall back to the process-global _active_profile which may belong to a different concurrent tab. Stamp s.profile in the compression migration block immediately after s.session_id = new_sid. Guarded by `if not s.profile` so sessions that already have a profile set are unaffected. A logger.info line records when the stamp fires, making future investigation straightforward. Fixes: memory writes bleeding into default profile after compression Reproduces: reliably on any long non-default profile session that hits the compression threshold (default: 0.80 context fill) --- api/streaming.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..0045020c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2022,7 +2022,24 @@ def _run_agent_streaming( except ImportError: _profile_home = os.environ.get('HERMES_HOME', '') _profile_runtime_env = {} - + + # Capture the resolved profile name now, while profile context is + # reliable. Used in the compression migration block to stamp s.profile + # on the continuation session. We resolve it here rather than calling + # get_active_profile_name() at compression time because that function + # reads thread-local storage (_tls.profile) set by set_request_profile() + # on the HTTP handler thread. The streaming thread is a separate + # threading.Thread and does not inherit TLS. At compression time, + # get_active_profile_name() would fall back to the process-global + # _active_profile, which may belong to a different concurrent tab. + _resolved_profile_name = getattr(s, 'profile', None) + if not _resolved_profile_name: + try: + from api.profiles import get_active_profile_name + _resolved_profile_name = get_active_profile_name() + except Exception: + _resolved_profile_name = None + _thread_env = _build_agent_thread_env( _profile_runtime_env, str(s.workspace), @@ -2972,6 +2989,22 @@ def _run_agent_streaming( old_path = SESSION_DIR / f'{old_sid}.json' new_path = SESSION_DIR / f'{new_sid}.json' s.session_id = new_sid + # Carry profile identity across the compression boundary. + # Without this, s.profile stays None on the continuation + # session. On the next request, _run_agent_streaming calls + # get_hermes_home_for_profile(getattr(s, 'profile', None)) + # which falls back to the default profile's HERMES_HOME. + # Memory writes then land in the wrong profile's MEMORY.md. + # Stamping here also ensures s.save() persists a non-null + # profile field to the continuation session's JSON file, + # covering the case where the session is later evicted from + # SESSIONS and reconstructed from disk via Session.load(). + if not s.profile and _resolved_profile_name: + s.profile = _resolved_profile_name + logger.info( + "Stamped profile=%r on continuation session %s after compression", + _resolved_profile_name, new_sid, + ) with LOCK: if old_sid in SESSIONS: SESSIONS[new_sid] = SESSIONS.pop(old_sid)