github →

New request

#2134: bug: background review agent writes to wrong profile's MEMORY.md

We'll provision a sandbox, run an agent against the issue, and open a draft PR. You can pull the branch and iterate from there.

Issue
bugupstream-change

Summary

The post-turn background review agent (memory/skill review) spawns with no profile context and initialises its memory provider from os.environ['HERMES_HOME'], which has been restored to the default profile's value by the time the background thread reaches AIAgent.__init__. Memory writes from the review agent land in the default profile's MEMORY.md regardless of which profile the parent session belongs to.

This is independent of the compression-rotation bug fixed in hermes-webui#2006. That fix addressed session rotation; this bug affects every non-default-profile session that triggers a background review, with no compression required.

Root cause

The race

The WebUI streaming handler (api/streaming.py) manages profile isolation through os.environ mutations:

# Line ~2042-2048: Before the turn
old_hermes_home = os.environ.get('HERMES_HOME')
os.environ['HERMES_HOME'] = _profile_home  # e.g. non-default profile path

# Line ~2736: Run the agent turn
result = agent.run_conversation(...)

# Line ~3419-3420: After the turn (finally block)
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home

Inside run_conversation(), at the end of the turn (line ~15147), _spawn_background_review() starts a daemon thread and returns immediately. The daemon thread then races with the finally block:

Thread A (streaming handler)              Thread B (bg-review)
─────────────────────────                 ─────────────────────
run_conversation() returns
                                          _run_review() begins
finally: os.environ['HERMES_HOME'] = old  
                                          AIAgent.__init__()
                                            load_config()         ← reads HERMES_HOME (now default)
                                            get_hermes_home()     ← returns default path
                                            mem_config["provider"] = "hindsight" (from default config)
                                            MemoryStore resolves to default MEMORY.md

No synchronisation exists between thread spawn and env restore. There is no threading.Event, Barrier, or join(). The thread simply starts and races.

What the background review agent reads

When the background AIAgent.__init__ runs:

  1. load_config() (line ~1867-1868) calls get_config_path()get_hermes_home() / "config.yaml" → reads os.environ['HERMES_HOME']. If the env has been restored, this returns the default profile's config.yaml.

  2. mem_config.get("provider") (line ~1914) reads the provider name from that config. Default profile has hindsight; non-default profile has holographic. Wrong config → wrong provider.

  3. get_hermes_home() (line ~1927) is passed to the memory provider's initialize_all() as hermes_home. Hindsight initialises against the default profile's home directory.

  4. MemoryStore (line ~4186) is inherited from the parent as review_agent._memory_store = self._memory_store. But MemoryStore.add() / MemoryStore.replace() call get_memory_dir() at write time, which calls get_hermes_home()os.environ['HERMES_HOME']. By write time, the env is the default profile's.

Both the built-in memory tool (via MemoryStore) and the external memory provider (via AIAgent.__init__ re-initialisation) resolve to the default profile. Memory writes from the background review agent land in the wrong MEMORY.md. The contamination is silent — no error, no warning.

Confirmed evidence

Log trace

A session on a non-default profile (using holographic as memory provider) was observed over ~25 minutes:

Time offsetEventMemory Provider
+0:00Session starts, holographic registeredholographic
+0:00 - +11:22Turns 1-15, normal operationholographic ✓
+11:22Turn 15 ends
+11:22Post-turn hook fireshindsight
+11:22Background skill review session spawnshindsight ✗
+16:35Turn 18 ends
+16:35Post-turn hook fires againhindsight
+16:35Background memory review session spawnshindsight ✗
+16:54Background session writes to memory (1,655 chars)lands in default MEMORY.md ✗
+16:59Background session writes to memory (1,961 chars)lands in default MEMORY.md ✗

Session file evidence

  • Parent session: stored in non-default profile's sessions dir; WebUI sidecar correctly shows the non-default profile
  • Background review session: stored in default sessions dir (~/.hermes/sessions/), profile: None, no WebUI sidecar
  • Default MEMORY.md: contains content from the non-default profile session that should never have been written there

No compression involved

Zero compression events in this session. 50 messages total. This is purely the background review agent race.

Suggested fixes

Option A: Thread-local HERMES_HOME override

Add a thread-local override to get_hermes_home() in hermes_constants.py:

import threading
_tls = threading.local()

def set_hermes_home_override(path: str):
    """Set a thread-local HERMES_HOME override."""
    _tls.hermes_home = path

def clear_hermes_home_override():
    """Clear the thread-local override."""
    _tls.hermes_home = None

def get_hermes_home() -> Path:
    # Thread-local override takes precedence
    override = getattr(_tls, 'hermes_home', None)
    if override:
        return Path(override)
    # ... existing os.environ logic ...

Then in _spawn_background_review:

_bg_hermes_home = os.environ.get('HERMES_HOME', '')

def _run_review():
    if _bg_hermes_home:
        set_hermes_home_override(_bg_hermes_home)
    try:
        # ... existing code ...
    finally:
        clear_hermes_home_override()

No process-global env mutation from the background thread. Thread-local is invisible to concurrent streaming turns. Also fixes the same class of problem for any future background threads that need profile isolation.

Option B: Accept hermes_home as an AIAgent constructor parameter

Add a hermes_home parameter to AIAgent.__init__ that overrides get_hermes_home() for the lifetime of that agent instance, threading the override through every downstream call site in the agent's dependency tree (config, memory, skills, etc.). Each agent instance becomes fully self-contained with no reliance on global state.

Related

  • hermes-webui#2006 — fixes the compression rotation path (merged)
  • hermes_constants.py line 14: get_hermes_home() — the single source of truth for HERMES_HOME
  • run_agent.py line 4117: _spawn_background_review() — the spawn site
  • run_agent.py line 1867-1963: AIAgent.__init__ memory provider init — reads config and hermes_home at init time
  • tools/memory_tool.py line 55-57: get_memory_dir()get_hermes_home() — resolves write target at write time, not init time

Reproduction

  1. Create a non-default profile with a different memory provider than the default (e.g. default uses hindsight, non-default uses holographic)
  2. Start a session on the non-default profile
  3. Send enough messages to trigger the memory/skill review nudge interval (default: 10 tool iterations)
  4. Check agent.log for Memory provider '<provider>' registered after the background review spawns — if it shows the default profile's provider instead of the active profile's, the bug is confirmed
  5. Check that any memory writes from the background review landed in the default profile's memories/MEMORY.md instead of the active profile's
Assessmentadvisory
bug●●● hard95% confidence

Race condition between environment variable restoration and daemon thread initialization requires thread-safety fixes.

Likely files
  • api/streaming.py
  • agent/core.py
  • memory/provider.py
  • tests/test_background_review.py
Create the request

This opens a fresh agent run and a draft PR for issue #2134.

Cancel