diff --git a/agent/agent_init.py b/agent/agent_init.py index 50ff4ae4bf..bd9b9478a6 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -587,6 +587,10 @@ _SESSION_STATE: Dict[str, Any] = { # prefix, kept separately only to place an early cache marker. "_cached_system_prompt": None, "_cached_system_prompt_static": None, + # skills.auto_load rendered ONCE per agent: every rebuild (model switch, compression, + # static-prefix restoration) reuses these exact bytes instead of re-reading config/skills. + "_auto_load_skills_resolved": False, + "_auto_load_skills_result": ("", [], []), # ``(cwd, workspace_block)`` pinned on the first build: the git/workspace snapshot is # probed once per session and replayed on every rebuild, so a moving repo can't push the # prefix-cache divergence point ahead of the volatile band at a compaction boundary. diff --git a/cli.py b/cli.py index 8930152446..a1e4c830d7 100644 --- a/cli.py +++ b/cli.py @@ -2932,6 +2932,9 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix self._prompt_stash = _PromptStash() self.preloaded_skills: list[str] = [] self._startup_skills_line_shown = False + # skills.auto_load rendered in the preload thread; None until joined. Handed to every + # agent this CLI builds so the prompt bytes never depend on when the agent was created. + self._auto_load_skills_result: Optional[tuple] = None # Background --skills preload, joined by finalize_preloaded_skills before any agent is built. self._preload_skills_thread: Optional[threading.Thread] = None self._preload_skills_result: Optional[tuple] = None @@ -3080,6 +3083,11 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix err = getattr(self, "_preload_skills_error", None) if err is not None: raise err + auto_result = getattr(self, "_auto_load_skills_result", None) + if auto_result and auto_result[2]: + logger.warning("skills.auto_load: skill(s) not found or disabled, skipped: %s", ", ".join(auto_result[2])) + # auto_load names first, then explicit -s names that were not already pinned. + self.preloaded_skills = list(auto_result[1]) if auto_result else [] result = getattr(self, "_preload_skills_result", None) if not result: return @@ -3099,7 +3107,7 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix raise ValueError(f"Unknown skill(s): {missing_display}") if skills_prompt: self.system_prompt = "\n\n".join(p for p in (self.system_prompt, skills_prompt) if p).strip() - self.preloaded_skills = loaded_skills + self.preloaded_skills += [name for name in loaded_skills if name not in self.preloaded_skills] def _show_tool_availability_warnings(self): """Warn about tools disabled by missing API keys (not system deps).""" @@ -4337,18 +4345,29 @@ def _build_cli_from_args(model, toolsets, provider, reasoning, api_key, base_url sys.exit(1) raise - if parsed_skills: + # skills.auto_load rides the same background preload as -s; --ignore-rules skips it with + # the rest of the auto-injected context. Resolved here (not lazily in the agent) so the + # session id is real for ${HERMES_SESSION_ID} and -s can dedupe against it. + from agent.skill_commands import build_auto_load_prompt, resolve_auto_load_skills + auto_load_names = [] if getattr(cli, "ignore_rules", ignore_rules) else resolve_auto_load_skills(CLI_CONFIG) + if not auto_load_names: + cli._auto_load_skills_result = ("", [], []) + if parsed_skills or auto_load_names: # Load the skill payloads in the background: skill_view walks the full skills # tree per skill (~0.5s for a large library) and the result is only consumed # at agent init, not by the banner. finalize_preloaded_skills() joins the # thread before any consumer reads cli.system_prompt. def _load_preloaded_skills() -> None: try: - cli._preload_skills_result = build_preloaded_skills_prompt(parsed_skills, task_id=cli.session_id) + if auto_load_names: + cli._auto_load_skills_result = build_auto_load_prompt(task_id=cli.session_id, user_config=CLI_CONFIG) + if parsed_skills: + cli._preload_skills_result = build_preloaded_skills_prompt( + parsed_skills, task_id=cli.session_id, excluded_loaded_names=set(cli._auto_load_skills_result[1])) except Exception as exc: # surfaced by finalize cli._preload_skills_error = exc - cli._preload_skills_requested = parsed_skills + cli._preload_skills_requested = [*auto_load_names, *(s for s in parsed_skills if s not in auto_load_names)] cli._preload_skills_thread = threading.Thread(target=_load_preloaded_skills, name="skills-preload", daemon=True) cli._preload_skills_thread.start() return cli diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 94adf5d4c4..f5ecb52e71 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -576,6 +576,12 @@ class CLIAgentSetupMixin: # ``cli._active_agent_ref`` None forever — so memory shutdown never ran on /exit (#49287). import cli as _cli _cli._active_agent_ref = self.agent + # Seed the agent's once-per-lifecycle auto_load cache with the bytes the preload + # thread rendered, so the shared prompt path never re-reads config or skill files. + _auto_result = getattr(self, "_auto_load_skills_result", None) + if _auto_result is not None: + self.agent._auto_load_skills_result = _auto_result + self.agent._auto_load_skills_resolved = True # Route agent status output through prompt_toolkit so ANSI escapes aren't garbled by # patch_stdout's StdoutProxy (#2262), holding lines while a response box streams so a # subagent/background completion notice never splits the reply mid-paragraph. diff --git a/tests/hermes_cli/test_cli_preloaded_skills.py b/tests/hermes_cli/test_cli_preloaded_skills.py index 732fee4e83..1c2b5e8bb6 100644 --- a/tests/hermes_cli/test_cli_preloaded_skills.py +++ b/tests/hermes_cli/test_cli_preloaded_skills.py @@ -94,7 +94,7 @@ def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch): monkeypatch.setattr( cli_mod, "build_preloaded_skills_prompt", - lambda skills, task_id=None: ("skill prompt", ["hermes-agent-dev", "github-auth"], []), + lambda skills, task_id=None, excluded_loaded_names=None: ("skill prompt", ["hermes-agent-dev", "github-auth"], []), ) with pytest.raises(SystemExit): @@ -122,7 +122,7 @@ def test_main_raises_for_unknown_preloaded_skill(monkeypatch): monkeypatch.setattr( cli_mod, "build_preloaded_skills_prompt", - lambda skills, task_id=None: ("", [], ["missing-skill"]), + lambda skills, task_id=None, excluded_loaded_names=None: ("", [], ["missing-skill"]), ) with pytest.raises(SystemExit): diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index de96e94bee..03aa91025b 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -287,6 +287,21 @@ hermes chat -s github-pr-workflow -s github-auth Hermes loads each named skill into the session prompt before the first turn. The same flag works in interactive mode and single-query mode. +### Persistent auto-load via config + +To have the same skills active at the start of **every** new session — CLI, TUI, gateway, cron and API sessions alike — set `skills.auto_load` in `config.yaml`: + +```yaml +skills: + auto_load: + - hermes-agent-dev + - github-pr-workflow +``` + +Each entry is a skill name. The list is resolved once when a session's system prompt is first built and the rendered bytes are reused for the life of the conversation (model switches, compression), so prompt caching stays intact; config edits take effect in the next session. Missing or disabled skills log a warning and are skipped. `-s` names that overlap the list are loaded once. + +`--ignore-rules` (equivalently `HERMES_IGNORE_RULES=1`) skips auto-load together with AGENTS.md, SOUL.md, `.cursorrules` and memory injection; explicit `-s` skills still load. The setting is profile-scoped: each profile's `config.yaml` controls its own list. + ## Skill Slash Commands Every installed skill in `~/.hermes/skills/` is automatically registered as a slash command. The skill name becomes the command: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index fc70c52a3d..a959f6aa4c 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -761,6 +761,19 @@ hermes config set skills.config.myplugin.path ~/myplugin-data For details on declaring config settings in your own skills, see [Creating Skills — Config Settings](/developer-guide/creating-skills#config-settings-configyaml). +### Auto-loading skills every session + +Pin skills so they are fully loaded at the start of every new session, on every surface: + +```yaml +skills: + auto_load: + - my-workflow + - github-pr-workflow +``` + +Resolved once per session when the system prompt is first built (so the prompt stays cache-stable; edits apply to the next session). Missing or disabled skills warn and are skipped; `--ignore-rules` / `HERMES_IGNORE_RULES=1` suppresses the list. Profile-scoped. See [CLI — persistent auto-load](/user-guide/cli#persistent-auto-load-via-config). + ### Guard on agent-created skill writes When the agent uses `skill_manage` to create, edit, patch, or delete a skill, Hermes can optionally scan the new/updated content for dangerous keyword patterns (credential harvesting, obvious prompt injection, exfil instructions). The scanner is **off by default** — real agent workflows that legitimately touch `~/.ssh/` or mention `$OPENAI_API_KEY` were tripping the heuristic too often. Turn it back on if you want the scanner to prompt you before the agent's skill writes land: