Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@ def _set_defaults(agent, table: Dict[str, Any]) -> None:
# 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.
Expand Down
65 changes: 62 additions & 3 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,11 +546,14 @@ def _disabled_skill_names(platform: str | None = None) -> set:
def _load_skill_blocks(
identifiers: list[str], load, activation_note, task_id: str | None, *,
missing_label=lambda ident: ident, disabled_names: set | None = None, disabled_as_missing: bool = False,
already_loaded: set | None = None,
) -> tuple[list[str], list[str], list[str], list[str]]:
"""Load each distinct identifier via *load* and render its block; returns
``(loaded_names, missing, disabled, blocks)``. With *disabled_names*, members
whose canonical (LOADED — identifiers may be paths) name or identifier is
disabled go to ``disabled`` (or ``missing`` when *disabled_as_missing*)."""
disabled go to ``disabled`` (or ``missing`` when *disabled_as_missing*).
Canonical names in *already_loaded* (e.g. skills.auto_load) count as resolved
but render no block, so one skill never lands in the prompt twice."""
loaded_names: list[str] = []
missing: list[str] = []
disabled: list[str] = []
Expand All @@ -571,16 +574,23 @@ def _load_skill_blocks(
else:
disabled.append(skill_name or identifier)
continue
if already_loaded and skill_name in already_loaded:
loaded_names.append(skill_name)
continue
blocks.append(_render_skill_block(loaded, activation_note(skill_name), task_id))
loaded_names.append(skill_name)
return loaded_names, missing, disabled, blocks


def build_preloaded_skills_prompt(skill_identifiers: list[str], task_id: str | None = None) -> tuple[str, list[str], list[str]]:
def build_preloaded_skills_prompt(
skill_identifiers: list[str], task_id: str | None = None, excluded_loaded_names: set[str] | None = None,
) -> tuple[str, list[str], list[str]]:
"""Load skills for session-wide CLI/TUI preloading; returns (prompt_text,
loaded_skill_names, missing_identifiers). Disabled skills count as missing:
this path bypasses the scan-time filter, and ``hermes -s <skill>`` must not
force-load an operator-disabled skill.
force-load an operator-disabled skill. *excluded_loaded_names* are canonical
names the session already carries (skills.auto_load): they resolve as loaded
but are not rendered again.

Disabled skills are treated the same as missing ones: this loads via a raw identifier straight into
``_load_skill_payload``, bypassing ``get_skill_commands()``'s scan-time disabled filter — mirrors the
Expand All @@ -593,5 +603,54 @@ def build_preloaded_skills_prompt(skill_identifiers: list[str], task_id: str | N
"preloaded. 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,
already_loaded=excluded_loaded_names,
)
return "\n\n".join(prompt_parts), loaded_names, missing


def resolve_auto_load_skills(user_config: dict | None = None) -> list[str]:
"""``skills.auto_load`` from *user_config* (else the active profile config), deduplicated;
empty when unset, malformed, or the config is unreadable."""
if user_config is None:
try:
from hermes_cli.config import load_config_readonly
user_config = load_config_readonly()
except Exception:
return []
skills_block = user_config.get("skills") if isinstance(user_config, dict) else None
auto_load = skills_block.get("auto_load") if isinstance(skills_block, dict) else None
if not isinstance(auto_load, list):
return []
names = [entry.strip() for entry in auto_load if isinstance(entry, str) and entry.strip()]
return list(dict.fromkeys(names))


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.

*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)
29 changes: 29 additions & 0 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,33 @@ def _skills_prompt(agent: Any) -> str:
compact_categories=_compact_cats or None, skills_dir_override=_agent_skills_dir(agent))


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.

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), 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:
logger.debug("skills.auto_load: injection skipped", exc_info=True) # config errors never block session start
agent._auto_load_skills_result = result
agent._auto_load_skills_resolved = True
prompt = agent._auto_load_skills_result[0]
return [prompt] if prompt else []


def _bot_mode_parts(agent: Any) -> List[str]:
"""Bot Mode teammate protocol — only in a bot's canonical "Bot Chat" session.
Marks the prompt timeless (the volatile date line is dropped) since a birth
Expand Down Expand Up @@ -627,6 +654,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if "skill_view" in (agent.valid_tool_names or set()) and "- hermes-agent:" in skills_prompt:
stable_parts[_help_guidance_slot] = HERMES_AGENT_HELP_GUIDANCE
stable_parts.extend(_alibaba_identity_part(agent))
# Pinned skills are per-agent constants (resolved once), so they live in the stable prefix.
stable_parts.extend(_auto_load_parts(agent))
# Coding posture: the operating brief stays in the stable prefix. The
# environment block contains the current cwd/backend and belongs after
# project context, not ahead of a large shared AGENTS.md block.
Expand Down
31 changes: 25 additions & 6 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def format_token_count_compact(*args, **kwargs):
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'
Expand Down Expand Up @@ -2769,7 +2769,7 @@ def _init_checkpoints_and_rules(self, checkpoints, pass_session_id, ignore_rules
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."""
Expand Down Expand Up @@ -2932,6 +2932,9 @@ def _init_ui_state(self):
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
Expand Down Expand Up @@ -3080,6 +3083,11 @@ def finalize_preloaded_skills(self) -> None:
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
Expand All @@ -3099,7 +3107,7 @@ def finalize_preloaded_skills(self) -> None:
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)."""
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,12 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
# ``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.
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,10 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
"project_discovery": True,
# Trusted project roots; managed by `hermes skills trust` / `untrust`.
"trusted_project_dirs": [],
# Skill names pinned as fully loaded in every new session (CLI, TUI, gateway, cron, API).
# Resolved once when the agent's prompt is first built; missing/disabled names warn and
# skip; HERMES_IGNORE_RULES suppresses the list like the other auto-injected context.
"auto_load": [],
# Substitute ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in SKILL.md content.
"template_vars": True,
# Pre-execute !`cmd` snippets in SKILL.md, inlining stdout (dates, git state...). Off:
Expand Down
Loading
Loading