Skip to content

fix: stamp profile on continuation session after context compression - #2006

Merged
1 commit merged into
nesquena:masterfrom
qxxaa:fix/compression-session-profile-bleed
May 10, 2026
Merged

fix: stamp profile on continuation session after context compression#2006
1 commit merged into
nesquena:masterfrom
qxxaa:fix/compression-session-profile-bleed

Conversation

@qxxaa

@qxxaa qxxaa commented May 10, 2026

Copy link
Copy Markdown
Contributor

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) read MEMORY.md at 16% / 1,184 chars with 4 entries. The troubleshooting profile's MEMORY.md was 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.

replace operations 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's MEMORY.md.

Timestamps establish causation: original session ended at 07:56:28, continuation session started at 07:56:28. The WebUI sidecar for the original session was not yet written at that moment (born 08: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, the SESSIONS dict, and renames the session file. It does not ensure s.profile is set on the continuation session.

There are two failure paths:

Path 1 — In-memory: If s.profile was None from the start (a legacy session or one created before this fix), the continuation session object carries null through the current request. On the next request, get_hermes_home_for_profile(None) falls back to the default profile's HERMES_HOME.

Path 2 — Persistence: s.save() persists "profile": null to the continuation session's JSON file (profile is in METADATA_FIELDS, models.py ~line 408). On the next request, Session.load(new_sid) reads back profile: null and get_hermes_home_for_profile(None) falls back to the default profile. This path triggers even if the in-memory object survives with s.profile intact — as soon as the session is evicted from the SESSIONS LRU and reconstructed from disk, the null persists.

Fix

Edit 1: Capture _resolved_profile_name at request entry (~line 2019), immediately after profile home resolution. At this point profile context is reliable: s.profile if already set, otherwise get_active_profile_name() which reads thread-local storage (_tls.profile) correctly set by the HTTP handler thread via set_request_profile().

Edit 2: Stamp s.profile = _resolved_profile_name in the compression migration block immediately after s.session_id = new_sid. Guarded by if not s.profile to avoid overwriting sessions that already have a profile set. A logger.info line 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 by set_request_profile() on the HTTP handler thread. The streaming thread is a separate threading.Thread — it does not inherit TLS. At compression time, calling get_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:

old_path = SESSION_DIR / f"{old_sid}.json"
new_path = SESSION_DIR / f"{new_sid}.json"
if old_path.exists():
    old_path.rename(new_path)

The sidecar is not always written before compression fires. When it does not exist, old_path.exists() is False, the rename is skipped, and the continuation session has no sidecar on disk at all. If the session is later evicted from the SESSIONS LRU, get_session(new_sid) calls Session.load(new_sid) which returns None — the session becomes unresolvable and the route handler may reconstruct it without a profile stamp via new_session(profile=body.get("profile")). If the browser does not send profile in that request body, the reconstructed session has profile = 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

  • Compression migration block: streaming.py ~line 2966
  • Profile resolution: api/profiles.pyget_hermes_home_for_profile()
  • Session.save() metadata fields: api/models.py ~line 408 ('profile' is in METADATA_FIELDS)
  • Similar migration precedent: SESSION_AGENT_CACHE migration in the same block (~line 2997)

Files changed

  • api/streaming.py — 2 edits, ~15 lines added

Testing

  1. Create a non-default profile in the WebUI (Settings → Profiles)
  2. Lower the compression threshold to force it quickly:
    compression:
      threshold: 0.10
  3. Start a session on the non-default profile and send enough messages to trigger compression (watch for the compressed SSE event or a session_id change in browser devtools network tab)
  4. After compression fires, check agent.log for: Stamped profile=<name> on continuation session <sid> after compression
  5. Instruct the agent to write something distinctive to memory
  6. Verify the write lands in the non-default profile's memories/MEMORY.md
  7. Verify the default profile's memories/MEMORY.md is unchanged
  8. Restart the WebUI process and repeat step 5 — confirms the persistence path (Session.load from disk) also routes correctly
  9. Restore compression.threshold to its prior value

To 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

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)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

Reading api/streaming.py:2022-2039 and :2989-3005 on the PR head + the same hunks on origin/master, plus api/profiles.py:200-228 for the TLS contract and api/models.py:411-413 for the persisted METADATA_FIELDS, the analysis in the PR body is correct and the patch is the right shape.

Code reference

The persistence half of the bug is real — s.profile lives in METADATA_FIELDS:

# api/models.py:411-413
'pinned', 'archived', 'project_id', 'profile',

so a None value survives every s.save(). And the TLS-vs-streaming-thread reasoning is also correct — _run_agent_streaming() is launched as a daemon threading.Thread from api/routes.py:6562-6567 and from the btw/background paths at :6334-6339 / :6391, which means _tls.profile set by the HTTP handler (server.py:130, 148) is not visible inside the streaming thread. get_active_profile_name() in api/profiles.py:200-211 would then fall through to the process-global _active_profile, exactly as the PR description warns:

def get_active_profile_name() -> str:
    tls_name = getattr(_tls, 'profile', None)
    if tls_name is not None:
        return tls_name
    return _active_profile

Capturing _resolved_profile_name at streaming.py:2031-2039, immediately after get_hermes_home_for_profile(getattr(s, 'profile', None)) resolves the profile home, is the right hook — at that point s.profile is preferred and the fallback to get_active_profile_name() runs while the streaming thread has just been spawned from the handler thread (the import-and-call still races against TLS clearing in server.py:140 finally, so leaning on s.profile first is correct).

Diagnosis

The compression migration block at streaming.py:2966-2995 already moves the per-session lock, SESSION_AGENT_CACHE, the SESSIONS dict entry, and renames the JSON sidecar — the omission of s.profile is consistent with the other migration gaps (e.g. it also doesn't migrate STREAMS/CANCEL_FLAGS keyed by old session id, but those aren't keyed by sid). The new if not s.profile and _resolved_profile_name: guard is conservative and correct: it never overwrites a profile that was set, and it logs when it fires so future debugging has a breadcrumb.

One small concern that's worth surfacing but not a blocker: the streaming-thread path in routes.py:6391 (_run_bg_and_notify for /api/background) calls _run_agent_streaming synchronously inside a daemon thread that was already detached from the HTTP handler. By the time _run_agent_streaming runs the early _resolved_profile_name = get_active_profile_name() fallback, the request handler has long since called clear_request_profile() in its finally block (server.py:141, 159). The fallback there will hit the process-global _active_profile rather than TLS — but in that path bg.profile is explicitly set via _new_session(profile=getattr(s, 'profile', None)) at routes.py:6371, so s.profile is non-None and the TLS fallback is never reached. The current patch is fine; it just means the fallback semantics differ between the foreground chat path and the background/btw paths.

Recommendation

Approve. 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/load

A second test stamping _resolved_profile_name only and confirming Session.load(new_sid).profile == "myprofile" would lock down the persistence half of the fix (Path 2 in the PR body), since that's the one most likely to silently regress.

Verification

Reading api/streaming.py on cron-pr-2006 branch: the new capture block is at lines 2025-2039 (correctly placed after the _profile_home import-and-resolve), and the stamp is at 2992-3003 (correctly placed immediately after s.session_id = new_sid and before the SESSIONS/lock migration). Both edits respect the if not s.profile guard so existing sessions with a profile are untouched. The 35-line diff is entirely additive — no existing behavior changes.

@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in a42adbe May 10, 2026
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 10, 2026
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
@qxxaa
qxxaa deleted the fix/compression-session-profile-bleed branch July 24, 2026 15:27
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants