Files
hermes-agent/agent/skill_commands.py
Teknium 44982309b8 refactor(agent/prompt): dispatch tables and helper extraction in display, context refs, breakdown, compaction
build_tool_preview -> _PREVIEW_BUILDERS per-tool table; git @refs -> _GIT_REFERENCE_ARGS; context_breakdown
_skills_block/_append_overflow dedupe; prune_pre_checkpoint_items summary retention folded into one closure;
build_skill_invocation_message reuses _render_skill_block; ruff SIM collapses; restored two compacted
cache-policy invariant comments.
2026-09-02 13:53:58 -07:00

764 lines
29 KiB
Python

"""Shared slash command helpers for skills (CLI and gateway both invoke /skill-name)."""
import json
import logging
import os
import re
import threading
from pathlib import Path
from typing import Any, Dict, Optional
from hermes_constants import display_hermes_home
from agent.prompt_cache_boundary import register_stable_prefix
from agent.skill_preprocessing import (
load_skills_config as _load_skills_config,
preprocess_skill_content,
)
logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {}
_skill_commands_platform: Optional[str] = None
_skill_commands_home: Optional[str] = None
# Guards the (map, platform-tag, home-tag) triple so publication and the
# freshness lookup always see a consistent snapshot. Scanning stays outside.
_publish_lock = threading.Lock()
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# A /skill (or /bundle) turn is expanded into a model-facing message embedding
# the full skill body. Memory providers that store the raw user turn would
# capture the body instead of what the user asked, so
# ``extract_user_instruction_from_skill_message`` recovers just the instruction.
#
# These markers MUST stay byte-identical to the builders (``_build_skill_message``
# here, ``build_bundle_invocation_message`` in agent/skill_bundles.py); the
# bundle markers are asserted in tests/openviking_plugin/test_openviking.py.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
# The skill name sits in the first quoted span of the activation note, for both
# the single-skill and the bundle header ("work" / "/clean /work").
_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"')
# SQL LIKE pattern matching a skill-expanded turn, for listing queries that
# recognize scaffolding before the row reaches Python. The prefix contains no
# LIKE wildcards, so it needs no ESCAPE clause.
SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%"
# Marks where a preview query joined the head and tail of a long scaffolded
# message; ``describe_skill_invocation`` cuts a description there rather than
# show the skill body on the far side.
SKILL_EXCERPT_JOINT = "\x1e"
def slugify_skill_name(name: str) -> str:
"""Normalize a skill/bundle name to a ``/command`` slug (``Foo Bar`` -> ``foo-bar``).
Strips non-alnum chars (``+``, ``/``) that would make invalid Telegram
command names downstream.
"""
cmd = name.lower().replace(" ", "-").replace("_", "-")
cmd = _SKILL_INVALID_CHARS.sub("", cmd)
return _SKILL_MULTI_HYPHEN.sub("-", cmd).strip("-")
def append_user_instruction(parts: list, instruction: str) -> str:
"""Append the instruction line to ``parts``; return the stable prefix.
The prefix ends exactly at the instruction marker so, registered with
``agent.prompt_cache_boundary``, the Anthropic cache planner can break on
the scaffold instead of caching the whole message as one atomic block.
Single construction site guarantees the prefix is a byte-prefix of the message.
"""
stable_prefix = "\n".join(parts) + "\n" + _SINGLE_SKILL_INSTRUCTION
parts.append(f"{_SINGLE_SKILL_INSTRUCTION}{instruction}")
return stable_prefix
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns the string unchanged when it is NOT scaffolding, the extracted
instruction when the scaffolding carried one, or ``None`` for a bare
``/skill`` invocation (nothing worth storing in memory).
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]:
"""Render a slash-skill-expanded turn the way the user typed it.
Returns ``"/work — fix the title leak"``, ``"/work"`` for a bare invocation,
or ``None`` when *content* is not skill scaffolding. Surfaces that summarize
a user turn (session titles, previews, ``/rewind``) use this so the skill's
own prose never masquerades as the user's. Pass ``separator=" "`` for the
literal invocation as typed (chat transcripts).
"""
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
return None
match = _SKILL_NAME_RE.match(content)
name = (match.group(1) if match else "").strip()
# Bundle headers already carry their typed "/a /b" keys; a single skill is a bare name.
label = name if name.startswith("/") else f"/{name}"
instruction = extract_user_instruction_from_skill_message(content)
if instruction and instruction is not content:
# An excerpt (head + tail joined by SKILL_EXCERPT_JOINT) can put the
# joint inside the span — keep only the side the marker was found on.
instruction = " ".join(instruction.split(SKILL_EXCERPT_JOINT)[0].split())
if instruction:
return f"{label}{separator}{instruction}" if name else instruction
return label if name else None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# The instruction follows the skill body, so the LAST marker is the user's
# (the body may quote the marker text).
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
return _cut_at(instruction, _RUNTIME_NOTE)
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundles put the instruction before the loaded skills, so the FIRST marker is the user's.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
return _cut_at(instruction, _BUNDLE_FIRST_SKILL_BLOCK)
def _cut_at(text: str, stop_marker: str) -> Optional[str]:
idx = text.find(stop_marker)
if idx >= 0:
text = text[:idx]
return text.strip() or None
def _resolve_skill_commands_platform() -> Optional[str]:
"""Current platform scope for disabled-skill filtering, or None (CLI, RL, scripts).
A change here invalidates the scan cache so each platform sees its own
``skills.platform_disabled`` view.
"""
try:
from gateway.session_context import get_session_env
resolved_platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
except Exception:
resolved_platform = os.getenv("HERMES_PLATFORM")
return resolved_platform or None
def _resolve_skill_commands_home() -> str:
"""Effective Hermes home the scan is scoped to.
Profiles carry their own ``skills.external_dirs``; a profile switch without
a platform change must still invalidate the scan cache.
"""
from hermes_constants import get_hermes_home
return str(get_hermes_home())
def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
"""Load a skill by name/path and return (loaded_payload, skill_dir, display_name)."""
raw_identifier = (skill_identifier or "").strip()
if not raw_identifier:
return None
try:
from tools.skills_tool import _skills_dir, skill_view
from agent.skill_utils import normalize_skill_lookup_name
normalized = normalize_skill_lookup_name(raw_identifier)
loaded_skill = json.loads(
skill_view(normalized, task_id=task_id, preprocess=False)
)
except Exception:
return None
if not loaded_skill.get("success"):
return None
skill_name = str(loaded_skill.get("name") or normalized)
skill_path = str(loaded_skill.get("path") or "")
skill_dir = None
# Prefer the absolute skill_dir from skill_view() (correct for external
# skills too); fall back to SKILLS_DIR-relative reconstruction for legacy responses.
abs_skill_dir = loaded_skill.get("skill_dir")
if abs_skill_dir:
skill_dir = Path(abs_skill_dir)
elif skill_path:
try:
skill_dir = _skills_dir() / Path(skill_path).parent
except Exception:
skill_dir = None
return loaded_skill, skill_dir, skill_name
def _bump_use(skill_name: str, task_id: str | None) -> None:
"""Track active usage for Curator lifecycle management; never fatal."""
try:
from tools.skill_usage import bump_use
bump_use(skill_name, task_id=task_id)
except Exception:
pass
def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None:
"""Append a ``[Skill config: ...]`` block with resolved ``metadata.hermes.config`` values.
Lets the agent see configured values without reading config.yaml itself.
Non-critical: any failure leaves the message without the block.
"""
try:
from agent.skill_utils import (
extract_skill_config_vars,
parse_frontmatter,
resolve_skill_config_values,
)
raw_content = str(loaded_skill.get("raw_content") or loaded_skill.get("content") or "")
if not raw_content:
return
frontmatter, _ = parse_frontmatter(raw_content)
config_vars = extract_skill_config_vars(frontmatter)
if not config_vars:
return
resolved = resolve_skill_config_values(config_vars)
if not resolved:
return
lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"]
for key, value in resolved.items():
display_val = str(value) if value else "(not set)"
lines.append(f" {key} = {display_val}")
lines.append("]")
parts.extend(lines)
except Exception:
pass
def _setup_note(loaded_skill: dict[str, Any]) -> Optional[str]:
if loaded_skill.get("setup_skipped"):
return (
"Required environment setup was skipped. Continue loading the skill "
"and explain any reduced functionality if it matters."
)
if loaded_skill.get("gateway_setup_hint"):
return loaded_skill["gateway_setup_hint"]
if loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"):
return loaded_skill["setup_note"]
return None
def _supporting_files(loaded_skill: dict[str, Any], skill_dir: Path | None) -> list[str]:
"""Skill-relative support file paths: from ``linked_files`` or a disk walk."""
supporting = []
for entries in (loaded_skill.get("linked_files") or {}).values():
if isinstance(entries, list):
supporting.extend(entries)
if not supporting and skill_dir:
for subdir in ("references", "templates", "scripts", "assets"):
subdir_path = skill_dir / subdir
if subdir_path.exists():
for f in sorted(subdir_path.rglob("*")):
if f.is_file() and not f.is_symlink():
supporting.append(str(f.relative_to(skill_dir)))
return supporting
def _build_skill_message(
loaded_skill: dict[str, Any],
skill_dir: Path | None,
activation_note: str,
user_instruction: str = "",
runtime_note: str = "",
session_id: str | None = None,
) -> str:
"""Format a loaded skill into a user/system message payload."""
from tools.skills_tool import _skills_dir
# Preprocess first so downstream blocks see the expanded content.
content = preprocess_skill_content(
str(loaded_skill.get("content") or ""),
skill_dir,
session_id,
skills_cfg=_load_skills_config(),
)
parts = [activation_note, "", content.strip()]
# Absolute skill dir lets the agent run bundled scripts without a skill_view() round-trip.
if skill_dir:
parts.append("")
parts.append(f"[Skill directory: {skill_dir}]")
parts.append(
"Resolve any relative paths in this skill (e.g. `scripts/foo.js`, "
"`templates/config.yaml`) against that directory, then run them "
"with the terminal tool using the absolute path."
)
_inject_skill_config(loaded_skill, parts)
setup_note = _setup_note(loaded_skill)
if setup_note:
parts.extend(["", f"[Skill setup note: {setup_note}]"])
supporting = _supporting_files(loaded_skill, skill_dir)
if supporting and skill_dir:
try:
skill_view_target = str(skill_dir.relative_to(_skills_dir()))
except ValueError:
skill_view_target = skill_dir.name # external dir — use the skill name
parts.append("")
parts.append(
"[This skill has supporting files (paths relative to the skill "
"directory above):]"
)
for sf in supporting:
parts.append(f"- {sf}")
parts.append(
f'\nLoad any of these with skill_view(name="{skill_view_target}", '
f'file_path="<path>"), or run scripts directly by absolute path '
f"(e.g. `node {skill_dir}/scripts/foo.js`)."
)
stable_prefix = None
if user_instruction:
parts.append("")
# Everything before the volatile instruction is a stable scaffold; the
# registered boundary lets the cache planner break there (see append_user_instruction).
stable_prefix = append_user_instruction(parts, user_instruction)
if runtime_note:
parts.append("")
parts.append(f"[Runtime note: {runtime_note}]")
message = "\n".join(parts)
if stable_prefix is not None and message.startswith(stable_prefix) and len(message) > len(stable_prefix):
register_stable_prefix(stable_prefix)
return message
def _render_skill_block(
loaded: tuple[dict[str, Any], Path | None, str],
activation_note: str,
task_id: str | None,
**message_kwargs: str,
) -> str:
"""Bump usage and build the message block for one loaded skill."""
loaded_skill, skill_dir, skill_name = loaded
_bump_use(skill_name, task_id)
return _build_skill_message(loaded_skill, skill_dir, activation_note, session_id=task_id, **message_kwargs)
def _scaffold_header(
subject: str,
loaded_names: list[str],
*,
lead_lines: list[str] | None = None,
missing: list[str] | None = None,
disabled: list[str] | None = None,
extra_instruction: str = "",
user_instruction: str = "",
) -> str:
"""Header for multi-skill messages (bundles and stacked invocations).
``subject`` (e.g. ``'"name" skill bundle'``) must end in " skill bundle" so
the bundle-format extractor in extract_user_instruction_from_skill_message()
applies unchanged.
"""
lines = [
f"[IMPORTANT: The user has invoked the {subject}, "
f"loading {len(loaded_names)} skills together. Treat every skill below "
"as active guidance for this turn.]",
"",
*(lead_lines or []),
f"Skills loaded: {', '.join(loaded_names)}",
]
if missing:
lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if disabled:
lines.append(
f"Skills disabled for this platform (skipped): {', '.join(disabled)}"
)
if extra_instruction:
lines.extend(["", f"Bundle instruction: {extra_instruction}"])
if user_instruction:
lines.extend(["", f"User instruction: {user_instruction}"])
return "\n".join(lines)
_SCAN_SKIP_PARTS = {'.git', '.github', '.hub', '.archive'}
def _scan_skill_md(skill_md: Path, disabled: set, seen_names: set, commands: Dict[str, Dict[str, Any]], resolve_command) -> None:
"""Register one SKILL.md in *commands* (no-op when filtered or colliding)."""
from tools.skills_tool import _parse_frontmatter, skill_matches_platform, skill_matches_environment
if any(part in _SCAN_SKIP_PARTS for part in skill_md.parts):
return
frontmatter, body = _parse_frontmatter(skill_md.read_text(encoding='utf-8'))
# OS gate is hard; environment gate (kanban/docker/s6) is offer-time only.
if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter):
return
name = frontmatter.get('name', skill_md.parent.name)
if name in seen_names or name in disabled:
return
description = frontmatter.get('description', '')
if not description:
for line in body.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
description = line[:80]
break
seen_names.add(name)
cmd_name = slugify_skill_name(name)
if not cmd_name:
return
# A collision with a core command (name or alias, via resolve_command) skips
# auto-registration; the skill stays loadable via /skill <name>.
if resolve_command(cmd_name) is not None:
logger.warning(
"Skill %r generates slash command '/%s' which "
"collides with a core Hermes command; skipping "
"auto-registration. Use '/skill %s' instead.",
name, cmd_name, name,
)
return
# Dedup on the slug too: "git_helper" and "git-helper" normalize the same.
# First-wins preserves project > local > external precedence.
cmd_key = f"/{cmd_name}"
if cmd_key in commands:
logger.warning(
"Skill %r maps to slash command %s already claimed "
"by %r; keeping the first and skipping this one.",
name, cmd_key, commands[cmd_key]["name"],
)
return
commands[cmd_key] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),
"skill_dir": str(skill_md.parent),
}
def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
"""Scan skill dirs and return {"/skill-name": {name, description, skill_md_path, skill_dir}}.
Builds into a local map and publishes once at the end: writing straight into
the global exposed partial results to overlapping scans, which then logged
bogus "already claimed" collisions against their own incumbents.
"""
global _skill_commands, _skill_commands_platform, _skill_commands_home
platform = _resolve_skill_commands_platform()
home = _resolve_skill_commands_home()
commands: Dict[str, Dict[str, Any]] = {}
try:
from tools.skills_tool import _skills_dir, _get_disabled_skill_names
from agent.skill_utils import (
get_external_skills_dirs,
get_project_skills_dirs,
iter_project_skill_files,
iter_skill_index_files,
)
from hermes_cli.commands import resolve_command
disabled = _get_disabled_skill_names()
seen_names: set = set()
# Precedence: project (through the quarantine chokepoint) > local > external.
# Resolve the local dir at call time: import-time SKILLS_DIR is frozen to
# the launch home, but a multiplexed profile scope may have changed it.
project_dirs = list(get_project_skills_dirs())
dirs_to_scan = list(project_dirs)
skills_dir = _skills_dir()
if skills_dir.exists():
dirs_to_scan.append(skills_dir)
dirs_to_scan.extend(get_external_skills_dirs())
for scan_dir in dirs_to_scan:
_iter = (
iter_project_skill_files(scan_dir)
if scan_dir in project_dirs
else iter_skill_index_files(scan_dir, "SKILL.md")
)
for skill_md in _iter:
try:
_scan_skill_md(skill_md, disabled, seen_names, commands, resolve_command)
except Exception:
continue
except Exception:
pass
# Publish map + tags as ONE step: a reader landing between bare assignments
# could accept the new map under a stale platform tag and serve another
# platform's disabled-skill view.
with _publish_lock:
_skill_commands = commands
_skill_commands_platform = platform
_skill_commands_home = home
return commands
def get_skill_commands() -> Dict[str, Dict[str, Any]]:
"""Return the current skill commands mapping (scan first if empty).
Rescans when the platform scope changes (one gateway serving Telegram and
Discord) or the active profile's home changes (Desktop profile switch), so
each sees its own ``platform_disabled`` / ``external_dirs`` view.
"""
current_platform = _resolve_skill_commands_platform()
current_home = _resolve_skill_commands_home()
with _publish_lock:
commands = _skill_commands
is_fresh = (
bool(commands)
and _skill_commands_platform == current_platform
and _skill_commands_home == current_home
)
if is_fresh:
return commands
# Scan outside the lock — file I/O and deferred imports; concurrent scans
# are safe since each builds its own map.
return scan_skill_commands()
def diff_command_snapshots(before: Dict[str, str], after: Dict[str, str]) -> Dict[str, Any]:
"""Diff two {name: description} snapshots into added/removed/unchanged/total.
Removed entries carry the pre-rescan description (the file may be gone).
"""
added_names = sorted(set(after) - set(before))
removed_names = sorted(set(before) - set(after))
return {
"added": [{"name": n, "description": after[n]} for n in added_names],
"removed": [{"name": n, "description": before[n]} for n in removed_names],
"unchanged": sorted(set(after) & set(before)),
"total": len(after),
}
def reload_skills() -> Dict[str, Any]:
"""Re-scan skill dirs and return a diff of the slash-command map.
Does NOT invalidate the skills system-prompt cache: skills are called by
name, so keeping the prompt cache intact means ``/reload-skills`` costs no
cache reset.
Returns ``{"added": [{name, description}], "removed": [...], "unchanged":
[names], "total": int, "commands": int}``; ``description`` is the full
frontmatter field (the system prompt index truncates it).
"""
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
return {
slash_key.lstrip("/"): (info or {}).get("description") or ""
for slash_key, info in cmds.items()
}
before = _snapshot(_skill_commands)
new_commands = scan_skill_commands()
result = diff_command_snapshots(before, _snapshot(new_commands))
result["commands"] = len(new_commands)
return result
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical ``/slug`` key, or None.
Underscores map to hyphens: Telegram disallows hyphens in bot commands, so
``/claude-code`` comes back as ``/claude_code``.
"""
if not command:
return None
cmd_key = f"/{command.replace('_', '-')}"
return cmd_key if cmd_key in get_skill_commands() else None
def build_skill_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
runtime_note: str = "",
) -> Optional[str]:
"""Build the user message for a skill slash command, or None if not found."""
skill_info = get_skill_commands().get(cmd_key)
if not skill_info:
return None
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
if not loaded:
return None
return _render_skill_block(
loaded,
f'[IMPORTANT: The user has invoked the "{loaded[2]}" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]",
task_id,
user_instruction=user_instruction,
runtime_note=runtime_note,
)
# ---------------------------------------------------------------------------
# Stacked slash-skill invocations — `/skill-a /skill-b do XYZ` loads every
# leading skill (up to _MAX_STACKED_SKILLS). The message reuses the BUNDLE
# scaffolding markers so the memory extractor needs no new plumbing.
# ---------------------------------------------------------------------------
_MAX_STACKED_SKILLS = 5
def split_stacked_skill_commands(rest: str) -> tuple[list[str], str]:
"""Consume further leading ``/skill`` tokens from *rest* (text after the first matched command).
Stops at the first token that is not a resolvable skill command (or a
repeat); that token onward is the user instruction. Returns
``(extra_cmd_keys, remaining_instruction)``.
"""
keys: list[str] = []
remaining = rest or ""
while len(keys) < _MAX_STACKED_SKILLS - 1:
stripped = remaining.lstrip()
if not stripped.startswith("/"):
break
parts = stripped.split(None, 1)
token = parts[0]
tail = parts[1] if len(parts) > 1 else ""
cmd_key = resolve_skill_command_key(token.lstrip("/"))
if cmd_key is None or cmd_key in keys:
break
keys.append(cmd_key)
remaining = tail
return keys, remaining.strip()
def build_stacked_skill_invocation_message(
cmd_keys: list[str],
user_instruction: str = "",
task_id: str | None = None,
) -> Optional[tuple[str, list[str], list[str]]]:
"""Build the user message for a stacked multi-skill slash invocation.
Returns ``(message, loaded_skill_names, missing_skill_names)`` or ``None``
when no skill could be loaded at all.
"""
commands = get_skill_commands()
loaded_names: list[str] = []
missing: list[str] = []
skill_blocks: list[str] = []
seen: set[str] = set()
for cmd_key in cmd_keys:
if not cmd_key or cmd_key in seen:
continue
seen.add(cmd_key)
skill_info = commands.get(cmd_key)
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id) if skill_info else None
if not loaded:
missing.append(cmd_key.lstrip("/"))
continue
skill_name = loaded[2]
# Must start with "[Loaded as part of the " — the bundle block marker.
skill_blocks.append(_render_skill_block(
loaded,
f'[Loaded as part of the stacked skill invocation "{skill_name}".]',
task_id,
))
loaded_names.append(skill_name)
if not skill_blocks:
return None
typed = " ".join(k for k in cmd_keys if k)
header = _scaffold_header(
f'"{typed}" stacked skill bundle',
loaded_names,
missing=missing,
user_instruction=user_instruction,
)
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
def build_preloaded_skills_prompt(
skill_identifiers: list[str],
task_id: 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 disabled filter,
so ``hermes -s <skill>`` must not force-load an operator-disabled skill.
"""
prompt_parts: list[str] = []
loaded_names: list[str] = []
missing: list[str] = []
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names()
except Exception:
disabled_names = set()
seen: set[str] = set()
for raw_identifier in skill_identifiers:
identifier = (raw_identifier or "").strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
loaded = _load_skill_payload(identifier, task_id=task_id)
if not loaded or loaded[2] in disabled_names or identifier in disabled_names:
missing.append(identifier)
continue
skill_name = loaded[2]
prompt_parts.append(_render_skill_block(
loaded,
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
"preloaded. Treat its instructions as active guidance for the duration of this "
"session unless the user overrides them.]",
task_id,
))
loaded_names.append(skill_name)
return "\n\n".join(prompt_parts), loaded_names, missing