diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 038b066b05..04ef794e74 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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 ``/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) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 96c4096b19..de0a774319 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -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: diff --git a/cli.py b/cli.py index a1e4c830d7..a3542610c8 100644 --- a/cli.py +++ b/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.""" diff --git a/tests/agent/test_skills_auto_load.py b/tests/agent/test_skills_auto_load.py index 8b81d598cd..7cfb013236 100644 --- a/tests/agent/test_skills_auto_load.py +++ b/tests/agent/test_skills_auto_load.py @@ -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()