fix: stamp profile on continuation session after context compression - #2006
Conversation
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)
SummaryReading Code referenceThe persistence half of the bug is real — # api/models.py:411-413
'pinned', 'archived', 'project_id', 'profile',so a def get_active_profile_name() -> str:
tls_name = getattr(_tls, 'profile', None)
if tls_name is not None:
return tls_name
return _active_profileCapturing DiagnosisThe compression migration block at One small concern that's worth surfacing but not a blocker: the streaming-thread path in routes.py:6391 ( RecommendationApprove. The patch is well-scoped, the failure-mode analysis matches the code, the guard is non-destructive, and the log line will help confirm fires in production. One nit: a regression test covering the migration would be valuable. The shape would be: def test_compression_migrates_profile_to_continuation_session():
s = Session(profile="myprofile", session_id="old-sid")
# simulate agent.session_id rotation
agent.session_id = "new-sid"
# invoke compression migration block
...
assert s.profile == "myprofile"
# also: simulate s.profile=None at start, _resolved_profile_name="myprofile"
# → after migration s.profile == "myprofile" and persists across save/loadA second test stamping VerificationReading |
a42adbe
…inuation session by @qxxaa
…inuation session by @qxxaa
…inuation session by @qxxaa
Problem
In multi-profile deployments, memory writes made after context compression fires silently target the default profile's
MEMORY.md, regardless of which profile is active in the browser. No exception is raised, no warning is logged.Confirmed evidence
Session
0dfefb(continuation after compression from a troubleshooting profile session) readMEMORY.mdat 16% / 1,184 chars with 4 entries. The troubleshooting profile'sMEMORY.mdwas at 72-77% / 5,000+ chars at that time. A 16% reading with 4 entries could only come from the default profile's memory bank.replaceoperations in the same continuation session failed with "No entry matched" because the agent was searching the wrong profile's bank — the target entries existed only in the troubleshooting profile. The contaminating entry was found verbatim in the default profile'sMEMORY.md.Timestamps establish causation: original session ended at
07:56:28, continuation session started at07:56:28. The WebUI sidecar for the original session was not yet written at that moment (born08:10) — a compounding factor described below.Root cause
The compression migration block in
_run_agent_streaming(~line 2966) correctly migrates the session lock,SESSION_AGENT_CACHE, theSESSIONSdict, and renames the session file. It does not ensures.profileis set on the continuation session.There are two failure paths:
Path 1 — In-memory: If
s.profilewasNonefrom the start (a legacy session or one created before this fix), the continuation session object carriesnullthrough the current request. On the next request,get_hermes_home_for_profile(None)falls back to the default profile'sHERMES_HOME.Path 2 — Persistence:
s.save()persists"profile": nullto the continuation session's JSON file (profileis inMETADATA_FIELDS,models.py~line 408). On the next request,Session.load(new_sid)reads backprofile: nullandget_hermes_home_for_profile(None)falls back to the default profile. This path triggers even if the in-memory object survives withs.profileintact — as soon as the session is evicted from theSESSIONSLRU and reconstructed from disk, the null persists.Fix
Edit 1: Capture
_resolved_profile_nameat request entry (~line 2019), immediately after profile home resolution. At this point profile context is reliable:s.profileif already set, otherwiseget_active_profile_name()which reads thread-local storage (_tls.profile) correctly set by the HTTP handler thread viaset_request_profile().Edit 2: Stamp
s.profile = _resolved_profile_namein the compression migration block immediately afters.session_id = new_sid. Guarded byif not s.profileto avoid overwriting sessions that already have a profile set. Alogger.infoline records when the stamp fires. This ensures the field survives both the current request and any future reconstruction from disk.Why not call
get_active_profile_name()at compression time?get_active_profile_name()reads thread-local storage (_tls.profile) set byset_request_profile()on the HTTP handler thread. The streaming thread is a separatethreading.Thread— it does not inherit TLS. At compression time, callingget_active_profile_name()falls back to the process-global_active_profile, which may belong to a different concurrent tab/profile. Capturing the value at request entry, where the handler thread's TLS is still valid, avoids this race entirely.Compounding factor: skipped sidecar rename
The compression block attempts to rename the WebUI sidecar file:
The sidecar is not always written before compression fires. When it does not exist,
old_path.exists()isFalse, the rename is skipped, and the continuation session has no sidecar on disk at all. If the session is later evicted from theSESSIONSLRU,get_session(new_sid)callsSession.load(new_sid)which returnsNone— the session becomes unresolvable and the route handler may reconstruct it without a profile stamp vianew_session(profile=body.get("profile")). If the browser does not sendprofilein that request body, the reconstructed session hasprofile = None, compounding the routing failure.This PR fixes the profile stamp. The sidecar rename gap — where the continuation session may have no WebUI disk representation at all if the process restarts between turns — is a separate latent issue and is scoped out of this change.
Related
streaming.py~line 2966api/profiles.py—get_hermes_home_for_profile()Session.save()metadata fields:api/models.py~line 408 ('profile'is inMETADATA_FIELDS)SESSION_AGENT_CACHEmigration in the same block (~line 2997)Files changed
api/streaming.py— 2 edits, ~15 lines addedTesting
Settings → Profiles)compressedSSE event or a session_id change in browser devtools network tab)agent.logfor:Stamped profile=<name> on continuation session <sid> after compressionmemories/MEMORY.mdmemories/MEMORY.mdis unchangedSession.loadfrom disk) also routes correctlycompression.thresholdto its prior valueTo verify the sidecar timing race specifically: delete the WebUI sidecar for the original session before compression fires (to simulate it not yet being written), then repeat steps 3-7. The profile stamp must still route correctly.
Model Used
Hermes Agent on Claude Opus 4.6