diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index bfdd6618ab..c3e194e1d3 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -2154,12 +2154,21 @@ def _truncate_content( return head + marker + tail -def load_soul_md(context_length: Optional[int] = None) -> Optional[str]: +def load_soul_md( + context_length: Optional[int] = None, + home_override: "Path | None" = None, +) -> Optional[str]: """Load SOUL.md from HERMES_HOME and return its content, or None. Used as the agent identity (slot #1 in the system prompt). When this returns content, ``build_context_files_prompt`` should be called with ``skip_soul=True`` so SOUL.md isn't injected twice. + + ``home_override`` scopes the read to an explicit profile home (the agent + knows its own home from its session_db path). Without it, resolution is + ambient — which on a thread that lost the HERMES_HOME ContextVar falls + back to the launch home and reads the wrong profile's SOUL.md (#50233, + same class as the skills-index leak fixed in #86313). """ try: from hermes_cli.config import ensure_hermes_home @@ -2167,7 +2176,8 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]: except Exception as e: logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e) - soul_path = get_hermes_home() / "SOUL.md" + _home = Path(home_override) if home_override is not None else get_hermes_home() + soul_path = _home / "SOUL.md" if not soul_path.exists(): return None try: @@ -2350,6 +2360,7 @@ def build_context_files_prompt( skip_soul: bool = False, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False, + home_override: "Path | None" = None, ) -> str: """Discover and load context files for the system prompt. @@ -2413,7 +2424,7 @@ def build_context_files_prompt( # SOUL.md from HERMES_HOME only — skip when already loaded as identity if not skip_soul: - soul_content = load_soul_md(context_length) + soul_content = load_soul_md(context_length, home_override=home_override) if soul_content: sections.append(soul_content) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 190c99bc90..fe4b83617a 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -352,7 +352,10 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # cwd project instructions disabled. _soul_loaded = False if agent.load_soul_identity or not agent.skip_context_files: - _soul_content = _r.load_soul_md(_ctx_len) + # Scope the SOUL.md read to the agent's OWN home (see _agent_home) — + # ambient resolution on a thread that lost the HERMES_HOME ContextVar + # reads the launch profile's SOUL.md instead (#50233). + _soul_content = _r.load_soul_md(_ctx_len, home_override=_agent_home(agent)) if _soul_content: stable_parts.append(_soul_content) _soul_loaded = True @@ -692,7 +695,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) context_files_prompt = _r.build_context_files_prompt( cwd=resolve_context_cwd(), skip_soul=_soul_loaded, context_length=_ctx_len, - allow_install_tree_fallback=agent.platform in ("cli", "tui")) + allow_install_tree_fallback=agent.platform in ("cli", "tui"), + home_override=_agent_home(agent)) if context_files_prompt: context_parts.append(context_files_prompt) diff --git a/tests/agent/test_soul_md_profile_isolation.py b/tests/agent/test_soul_md_profile_isolation.py new file mode 100644 index 0000000000..c2be9de97a --- /dev/null +++ b/tests/agent/test_soul_md_profile_isolation.py @@ -0,0 +1,62 @@ +"""Regression (#50233, SOUL.md half): a profile agent's SOUL.md must load from +ITS OWN home, never the ambient/launch home — even on a thread that did not +bind the HERMES_HOME ContextVar. Same bug class as the skills-index leak +fixed in #86313; load_soul_md now accepts home_override. +""" + +import threading + + +def test_soul_md_scoped_to_home_override_not_ambient(tmp_path, monkeypatch): + from agent import prompt_builder + + default_home = tmp_path / "default" + default_home.mkdir() + (default_home / "SOUL.md").write_text( + "DEFAULT SOUL — must never leak into a profile prompt", encoding="utf-8" + ) + + bot_home = tmp_path / "profiles" / "mybot" + bot_home.mkdir(parents=True) + (bot_home / "SOUL.md").write_text("BOT SOUL", encoding="utf-8") + + # Ambient home points at default (mimics a build thread that lost the + # profile's ContextVar override and fell back to launch). + monkeypatch.setenv("HERMES_HOME", str(default_home)) + + result = {} + + def build(): + result["soul"] = prompt_builder.load_soul_md(home_override=bot_home) + + t = threading.Thread(target=build) + t.start() + t.join() + + assert result["soul"] == "BOT SOUL" + + +def test_soul_md_override_missing_file_returns_none(tmp_path, monkeypatch): + from agent import prompt_builder + + default_home = tmp_path / "default" + default_home.mkdir() + (default_home / "SOUL.md").write_text("DEFAULT SOUL", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(default_home)) + + empty_bot = tmp_path / "profiles" / "emptybot" + empty_bot.mkdir(parents=True) + + # No SOUL.md in the bot home -> None, NOT default's soul. + assert prompt_builder.load_soul_md(home_override=empty_bot) is None + + +def test_soul_md_ambient_unchanged_without_override(tmp_path, monkeypatch): + from agent import prompt_builder + + home = tmp_path / "home" + home.mkdir() + (home / "SOUL.md").write_text("AMBIENT SOUL", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert prompt_builder.load_soul_md() == "AMBIENT SOUL" diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index b881a356de..aac357bd75 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -35,7 +35,7 @@ def _captured_context_cwd(agent): def fake_context_files( cwd=None, skip_soul=False, context_length=None, - allow_install_tree_fallback=False, + allow_install_tree_fallback=False, home_override=None, ): captured["cwd"] = cwd return ""