fix(skills): auto-load resolves under the agent's own home and skips internal forks

Why: build_auto_load_prompt read config via ambient load_config_readonly()
and looked skills up under the ambient SKILLS_DIR. Gateway bot threads lose
the HERMES_HOME ContextVar, so a bot profile's pinned skills came from the
launch profile — the docs promise profile scoping. _auto_load_parts now
passes home_override=_agent_home(agent) and build_auto_load_prompt binds it
for config, disabled-list and <home>/skills lookup, the same seam
_skills_prompt uses via skills_dir_override.

_auto_load_parts was unconditional and injected pinned SKILL.md bytes into
delegate children, curator/background_review forks and gateway hygiene
agents; it now mirrors _skills_prompt's gate (nothing without the skills
toolset) and returns [] when skip_context_files is set.

cli.py's HERMES_IGNORE_RULES check used == "1" while system_prompt used
is_truthy_value; both use is_truthy_value now.

Tests stay at 4: the build test asserts the home-scoped resolution, the
ignore-rules test also covers the subagent / no-skills-toolset gates.
This commit is contained in:
teknium1
2026-09-14 21:23:27 -07:00
committed by Teknium
parent 075a256597
commit 286e723db8
4 changed files with 62 additions and 29 deletions

View File

@@ -625,19 +625,32 @@ def resolve_auto_load_skills(user_config: dict | None = None) -> list[str]:
return list(dict.fromkeys(names))
def build_auto_load_prompt(task_id: str | None = None, user_config: dict | None = None) -> tuple[str, list[str], list[str]]:
def build_auto_load_prompt(
task_id: str | None = None, user_config: dict | None = None, home_override: Path | None = None,
) -> tuple[str, list[str], list[str]]:
"""Render ``skills.auto_load`` as fully loaded skill blocks for a new session; returns
``(prompt_text, loaded_names, missing)``. Missing and operator-disabled names are reported,
never raised: a typo in config must not block session start on any surface."""
auto_skills = resolve_auto_load_skills(user_config)
if not auto_skills:
return "", [], []
loaded_names, missing, _disabled, prompt_parts = _load_skill_blocks(
auto_skills,
lambda identifier: _load_skill_payload(identifier, task_id=task_id),
lambda name: (f'[IMPORTANT: The "{name}" skill is auto-loaded via config (skills.auto_load). '
"Treat its instructions as active guidance for the duration of this session unless "
"the user overrides them.]"),
task_id, disabled_names=_disabled_skill_names(), disabled_as_missing=True,
)
return "\n\n".join(prompt_parts), loaded_names, missing
never raised: a typo in config must not block session start on any surface.
*home_override* makes home resolution EXPLICIT (same seam as ``build_skills_system_prompt``): the config,
the disabled list and the ``<home>/skills`` lookup all resolve under that home, so a gateway build thread
that lost the HERMES_HOME ContextVar cannot pin the launch profile's skills into another profile's prompt.
"""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
home_token = set_hermes_home_override(str(home_override)) if home_override is not None else None
try:
auto_skills = resolve_auto_load_skills(user_config)
if not auto_skills:
return "", [], []
loaded_names, missing, _disabled, prompt_parts = _load_skill_blocks(
auto_skills,
lambda identifier: _load_skill_payload(identifier, task_id=task_id),
lambda name: (f'[IMPORTANT: The "{name}" skill is auto-loaded via config (skills.auto_load). '
"Treat its instructions as active guidance for the duration of this session unless "
"the user overrides them.]"),
task_id, disabled_names=_disabled_skill_names(), disabled_as_missing=True,
)
return "\n\n".join(prompt_parts), loaded_names, missing
finally:
if home_token is not None:
reset_hermes_home_override(home_token)

View File

@@ -315,13 +315,20 @@ def _skills_prompt(agent: Any) -> str:
def _auto_load_parts(agent: Any) -> List[str]:
"""``skills.auto_load`` blocks, resolved once per agent lifecycle (config, skill files and
HERMES_IGNORE_RULES are read on the first build only) so the prompt stays byte-stable
across model switches, compression and static-prefix restoration."""
across model switches, compression and static-prefix restoration.
Same gate as ``_skills_prompt``: nothing without the skills toolset, and nothing for agents that skip
context files (delegate children, curator/review forks, gateway hygiene agents) — pinned skills are
operator guidance for the user's session, not payload for every internal fork."""
if getattr(agent, "skip_context_files", False) or not any(
name in agent.valid_tool_names for name in ("skills_list", "skill_view", "skill_manage")):
return []
if not getattr(agent, "_auto_load_skills_resolved", False):
result: Tuple[str, List[str], List[str]] = ("", [], [])
try:
if not is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")):
from agent.skill_commands import build_auto_load_prompt
result = build_auto_load_prompt(task_id=getattr(agent, "session_id", None))
result = build_auto_load_prompt(task_id=getattr(agent, "session_id", None), home_override=_agent_home(agent))
if result[2]:
logger.warning("skills.auto_load: skill(s) not found or disabled, skipped: %s", ", ".join(result[2]))
except Exception:

4
cli.py
View File

@@ -171,7 +171,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
from hermes_constants import get_hermes_home
from hermes_state_ids import new_session_id
from hermes_cli.env_loader import load_hermes_dotenv
from utils import base_url_host_matches, base_url_hostname, fast_safe_load
from utils import base_url_host_matches, base_url_hostname, fast_safe_load, is_truthy_value
_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
@@ -2769,7 +2769,7 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix
self.checkpoint_max_file_size_mb = cp_cfg.get("max_file_size_mb", 10)
self.pass_session_id = pass_session_id
# --ignore-rules: AIAgent skips context files (AGENTS.md/SOUL.md/...) and memory.
self.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
self.ignore_rules = ignore_rules or is_truthy_value(os.environ.get("HERMES_IGNORE_RULES"))
def _init_prompt_and_reasoning(self, reasoning):
"""Ephemeral system prompt/prefill, reasoning + service tier, OpenRouter routing knobs, fallback chain."""

View File

@@ -20,7 +20,8 @@ def _bare_agent(session_id="auto-load-test"):
agent.model = "test-model"
agent.provider = "test"
agent.pass_session_id = False
agent.skip_context_files = True
agent.skip_context_files = False
agent._context_cwd_is_launch_artifact = True # no project-context walk; keeps the build tmp-home only
agent.load_soul_identity = False
agent._memory_enabled = False
agent._user_profile_enabled = False
@@ -38,12 +39,14 @@ def _bare_agent(session_id="auto-load-test"):
class TestBuildAutoLoadPrompt:
def test_loads_configured_skills_and_reports_missing(self, tmp_path):
"""Config AND skill lookup resolve under *home_override* (profile-scoped), not the ambient home."""
from agent.skill_commands import build_auto_load_prompt
_write_skill(tmp_path, "pinned-skill", "PINNED CONTENT")
cfg = {"skills": {"auto_load": ["pinned-skill", " pinned-skill ", "no-such-skill", 7]}}
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
prompt, loaded, missing = build_auto_load_prompt(task_id="s1", user_config=cfg)
home = tmp_path / "profile-home"
_write_skill(home / "skills", "pinned-skill", "PINNED CONTENT")
(home / "config.yaml").write_text(
"skills:\n auto_load: ['pinned-skill', ' pinned-skill ', 'no-such-skill', 7]\n", encoding="utf-8")
prompt, loaded, missing = build_auto_load_prompt(task_id="s1", home_override=home)
assert loaded == ["pinned-skill"]
assert missing == ["no-such-skill"]
assert "PINNED CONTENT" in prompt
@@ -81,13 +84,23 @@ class TestSharedPromptPath:
rebuilt = agent._build_system_prompt()
assert "ORIGINAL SKILL BYTES" in rebuilt and "MUTATED BYTES" not in rebuilt
def test_ignore_rules_suppresses_auto_load(self, tmp_path, monkeypatch):
def test_gates_suppress_auto_load(self, tmp_path, monkeypatch):
"""HERMES_IGNORE_RULES, skip_context_files (delegate children / internal forks) and a session without
the skills toolset all keep pinned skills out of the prompt."""
_write_skill(tmp_path, "stable-skill", "ORIGINAL SKILL BYTES")
cfg = {"skills": {"auto_load": ["stable-skill"]}}
monkeypatch.setenv("HERMES_IGNORE_RULES", "1")
agent = _bare_agent()
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
patch("hermes_cli.config.load_config_readonly", return_value=cfg):
prompt = agent._build_system_prompt()
assert "ORIGINAL SKILL BYTES" not in prompt
assert agent._auto_load_skills_resolved is True and agent._auto_load_skills_result == ("", [], [])
monkeypatch.setenv("HERMES_IGNORE_RULES", "true")
agent = _bare_agent()
assert "ORIGINAL SKILL BYTES" not in agent._build_system_prompt()
assert agent._auto_load_skills_resolved is True and agent._auto_load_skills_result == ("", [], [])
monkeypatch.delenv("HERMES_IGNORE_RULES")
child = _bare_agent("child")
child.skip_context_files = True
assert "ORIGINAL SKILL BYTES" not in child._build_system_prompt()
no_skills = _bare_agent("no-skills")
no_skills.valid_tool_names = {"memory"}
assert "ORIGINAL SKILL BYTES" not in no_skills._build_system_prompt()
assert "ORIGINAL SKILL BYTES" in _bare_agent("full")._build_system_prompt()