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:
@@ -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)
|
||||
|
||||
@@ -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
4
cli.py
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user