/ agent / skill_preprocessing.py
skill_preprocessing.py
  1  """Shared SKILL.md preprocessing helpers."""
  2  
  3  import logging
  4  import re
  5  import subprocess
  6  from pathlib import Path
  7  
  8  logger = logging.getLogger(__name__)
  9  
 10  # Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md.
 11  # Tokens that don't resolve (e.g. ${HERMES_SESSION_ID} with no session) are
 12  # left as-is so the user can debug them.
 13  _SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}")
 14  
 15  # Matches inline shell snippets like:  !`date +%Y-%m-%d`
 16  # Non-greedy, single-line only -- no newlines inside the backticks.
 17  _INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`")
 18  
 19  # Cap inline-shell output so a runaway command can't blow out the context.
 20  _INLINE_SHELL_MAX_OUTPUT = 4000
 21  
 22  
 23  def load_skills_config() -> dict:
 24      """Load the ``skills`` section of config.yaml (best-effort)."""
 25      try:
 26          from hermes_cli.config import load_config
 27  
 28          cfg = load_config() or {}
 29          skills_cfg = cfg.get("skills")
 30          if isinstance(skills_cfg, dict):
 31              return skills_cfg
 32      except Exception:
 33          logger.debug("Could not read skills config", exc_info=True)
 34      return {}
 35  
 36  
 37  def substitute_template_vars(
 38      content: str,
 39      skill_dir: Path | None,
 40      session_id: str | None,
 41  ) -> str:
 42      """Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in skill content.
 43  
 44      Only substitutes tokens for which a concrete value is available --
 45      unresolved tokens are left in place so the author can spot them.
 46      """
 47      if not content:
 48          return content
 49  
 50      skill_dir_str = str(skill_dir) if skill_dir else None
 51  
 52      def _replace(match: re.Match) -> str:
 53          token = match.group(1)
 54          if token == "HERMES_SKILL_DIR" and skill_dir_str:
 55              return skill_dir_str
 56          if token == "HERMES_SESSION_ID" and session_id:
 57              return str(session_id)
 58          return match.group(0)
 59  
 60      return _SKILL_TEMPLATE_RE.sub(_replace, content)
 61  
 62  
 63  def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
 64      """Execute a single inline-shell snippet and return its stdout (trimmed).
 65  
 66      Failures return a short ``[inline-shell error: ...]`` marker instead of
 67      raising, so one bad snippet can't wreck the whole skill message.
 68      """
 69      try:
 70          completed = subprocess.run(
 71              ["bash", "-c", command],
 72              cwd=str(cwd) if cwd else None,
 73              capture_output=True,
 74              text=True,
 75              timeout=max(1, int(timeout)),
 76              check=False,
 77          )
 78      except subprocess.TimeoutExpired:
 79          return f"[inline-shell timeout after {timeout}s: {command}]"
 80      except FileNotFoundError:
 81          return "[inline-shell error: bash not found]"
 82      except Exception as exc:
 83          return f"[inline-shell error: {exc}]"
 84  
 85      output = (completed.stdout or "").rstrip("\n")
 86      if not output and completed.stderr:
 87          output = completed.stderr.rstrip("\n")
 88      if len(output) > _INLINE_SHELL_MAX_OUTPUT:
 89          output = output[:_INLINE_SHELL_MAX_OUTPUT] + "...[truncated]"
 90      return output
 91  
 92  
 93  def expand_inline_shell(
 94      content: str,
 95      skill_dir: Path | None,
 96      timeout: int,
 97  ) -> str:
 98      """Replace every !`cmd` snippet in ``content`` with its stdout.
 99  
100      Runs each snippet with the skill directory as CWD so relative paths in
101      the snippet work the way the author expects.
102      """
103      if "!`" not in content:
104          return content
105  
106      def _replace(match: re.Match) -> str:
107          cmd = match.group(1).strip()
108          if not cmd:
109              return ""
110          return run_inline_shell(cmd, skill_dir, timeout)
111  
112      return _INLINE_SHELL_RE.sub(_replace, content)
113  
114  
115  def preprocess_skill_content(
116      content: str,
117      skill_dir: Path | None,
118      session_id: str | None = None,
119      skills_cfg: dict | None = None,
120  ) -> str:
121      """Apply configured SKILL.md template and inline-shell preprocessing."""
122      if not content:
123          return content
124  
125      cfg = skills_cfg if isinstance(skills_cfg, dict) else load_skills_config()
126      if cfg.get("template_vars", True):
127          content = substitute_template_vars(content, skill_dir, session_id)
128      if cfg.get("inline_shell", False):
129          timeout = int(cfg.get("inline_shell_timeout", 10) or 10)
130          content = expand_inline_shell(content, skill_dir, timeout)
131      return content