fix(skills): auto-load resolves once per agent and dedupes against -s

The system prompt must stay byte-stable for the life of a conversation:
`_auto_load_skills_result` is seeded in `_SESSION_STATE` and filled on
the FIRST prompt build only (HERMES_IGNORE_RULES captured then too), so
model switches, compression and static-prefix restoration reuse the
exact rendered bytes rather than re-reading config or skill files.

CLI: auto_load renders in the existing background `--skills` preload
thread (real session id for ${HERMES_SESSION_ID}), `-s` names dedupe
against the auto-loaded canonical names via
`build_preloaded_skills_prompt(excluded_loaded_names=)`, the activated
skills line shows auto_load first, and the lazily built agent is seeded
with the pre-resolved bytes. `--ignore-rules` skips auto-load with the
rest of the auto-injected context.

Re-implementation of #74060 by @ctaylor86 against current main.
This commit is contained in:
Carl Taylor
2026-09-14 19:03:39 -07:00
committed by Teknium
parent 1976869c01
commit 075a256597
6 changed files with 63 additions and 6 deletions
+4
View File
@@ -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.
+23 -4
View File
@@ -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
+6
View File
@@ -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.
@@ -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):
+15
View File
@@ -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:
+13
View File
@@ -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: