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:
+14
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user