fix: stamp profile on continuation session after context compression
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)
This commit is contained in:
1 parent
e4a9c5b7f5
commit
f665e50738
1 file changed
+34
-1
+34
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user