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.
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:
-
load_config()(line ~1867-1868) callsget_config_path()→get_hermes_home() / "config.yaml"→ readsos.environ['HERMES_HOME']. If the env has been restored, this returns the default profile'sconfig.yaml. -
mem_config.get("provider")(line ~1914) reads the provider name from that config. Default profile hashindsight; non-default profile hasholographic. Wrong config → wrong provider. -
get_hermes_home()(line ~1927) is passed to the memory provider'sinitialize_all()ashermes_home. Hindsight initialises against the default profile's home directory. -
MemoryStore(line ~4186) is inherited from the parent asreview_agent._memory_store = self._memory_store. ButMemoryStore.add()/MemoryStore.replace()callget_memory_dir()at write time, which callsget_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 offset | Event | Memory Provider |
|---|---|---|
+0:00 | Session starts, holographic registered | holographic ✓ |
+0:00 - +11:22 | Turns 1-15, normal operation | holographic ✓ |
+11:22 | Turn 15 ends | |
+11:22 | Post-turn hook fires | hindsight ✗ |
+11:22 | Background skill review session spawns | hindsight ✗ |
+16:35 | Turn 18 ends | |
+16:35 | Post-turn hook fires again | hindsight ✗ |
+16:35 | Background memory review session spawns | hindsight ✗ |
+16:54 | Background session writes to memory (1,655 chars) | lands in default MEMORY.md ✗ |
+16:59 | Background 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.pyline 14:get_hermes_home()— the single source of truth for HERMES_HOMErun_agent.pyline 4117:_spawn_background_review()— the spawn siterun_agent.pyline 1867-1963:AIAgent.__init__memory provider init — reads config and hermes_home at init timetools/memory_tool.pyline 55-57:get_memory_dir()→get_hermes_home()— resolves write target at write time, not init time
Reproduction
- Create a non-default profile with a different memory provider than the default (e.g. default uses
hindsight, non-default usesholographic) - Start a session on the non-default profile
- Send enough messages to trigger the memory/skill review nudge interval (default: 10 tool iterations)
- Check agent.log for
Memory provider '<provider>' registeredafter the background review spawns — if it shows the default profile's provider instead of the active profile's, the bug is confirmed - Check that any memory writes from the background review landed in the default profile's
memories/MEMORY.mdinstead of the active profile's
Race condition between environment variable restoration and daemon thread initialization requires thread-safety fixes.
- api/streaming.py
- agent/core.py
- memory/provider.py
- tests/test_background_review.py