fix(agent): scope SOUL.md load to the agent's own profile home (#50233)

load_soul_md resolved the home ambiently, so a build thread that lost the
HERMES_HOME ContextVar read the launch profile's SOUL.md into another
profile's prompt — same class as the skills-index leak fixed in #86313.
load_soul_md and build_context_files_prompt now accept home_override, and
build_system_prompt_parts passes the agent's own home (from session_db)
at both SOUL call sites. Ambient behavior unchanged when no override.
This commit is contained in:
Teknium
2026-08-14 14:06:56 -07:00
parent 4691eb5433
commit 1f1b4d9947
4 changed files with 83 additions and 6 deletions
+14 -3
View File
@@ -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)
+6 -2
View File
@@ -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)
@@ -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"
+1 -1
View File
@@ -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 ""