refactor(agent/subdirectory_hints,skill_preprocessing): fold hint-file lookup, compact exception ladders and docs
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Shared SKILL.md preprocessing helpers."""
|
||||
"""Shared SKILL.md preprocessing helpers: ``${HERMES_*}`` template tokens and
|
||||
inline ``!`cmd``` shell expansion."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -9,15 +10,11 @@ from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md.
|
||||
# Tokens that don't resolve (e.g. ${HERMES_SESSION_ID} with no session) are
|
||||
# left as-is so the user can debug them.
|
||||
# ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens. Unresolvable ones (e.g. no
|
||||
# session) are left as-is so the author can spot them.
|
||||
_SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}")
|
||||
|
||||
# Matches inline shell snippets like: !`date +%Y-%m-%d`
|
||||
# Non-greedy, single-line only -- no newlines inside the backticks.
|
||||
# Inline shell snippets like !`date +%Y-%m-%d` — single-line only.
|
||||
_INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`")
|
||||
|
||||
# Cap inline-shell output so a runaway command can't blow out the context.
|
||||
_INLINE_SHELL_MAX_OUTPUT = 4000
|
||||
|
||||
@@ -27,8 +24,7 @@ def load_skills_config() -> dict:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
|
||||
cfg = load_config_readonly() or {}
|
||||
skills_cfg = cfg.get("skills")
|
||||
skills_cfg = (load_config_readonly() or {}).get("skills")
|
||||
if isinstance(skills_cfg, dict):
|
||||
return skills_cfg
|
||||
except Exception:
|
||||
@@ -36,38 +32,21 @@ def load_skills_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def substitute_template_vars(
|
||||
content: str,
|
||||
skill_dir: Path | None,
|
||||
session_id: str | None,
|
||||
) -> str:
|
||||
"""Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in skill content.
|
||||
|
||||
Only substitutes tokens for which a concrete value is available --
|
||||
unresolved tokens are left in place so the author can spot them.
|
||||
"""
|
||||
def substitute_template_vars(content: str, skill_dir: Path | None, session_id: str | None) -> str:
|
||||
"""Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID}; tokens without a value stay in place."""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
skill_dir_str = str(skill_dir) if skill_dir else None
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
token = match.group(1)
|
||||
if token == "HERMES_SKILL_DIR" and skill_dir_str:
|
||||
return skill_dir_str
|
||||
if token == "HERMES_SESSION_ID" and session_id:
|
||||
return str(session_id)
|
||||
return match.group(0)
|
||||
|
||||
return _SKILL_TEMPLATE_RE.sub(_replace, content)
|
||||
values = {
|
||||
"HERMES_SKILL_DIR": str(skill_dir) if skill_dir else None,
|
||||
"HERMES_SESSION_ID": str(session_id) if session_id else None,
|
||||
}
|
||||
return _SKILL_TEMPLATE_RE.sub(lambda m: values[m.group(1)] or m.group(0), content)
|
||||
|
||||
|
||||
def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
"""Execute a single inline-shell snippet and return its stdout (trimmed).
|
||||
|
||||
Failures return a short ``[inline-shell error: ...]`` marker instead of
|
||||
raising, so one bad snippet can't wreck the whole skill message.
|
||||
"""
|
||||
"""Run one inline-shell snippet and return its stdout (trimmed; stderr when
|
||||
stdout is empty). Failures return an ``[inline-shell ...]`` marker instead
|
||||
of raising, so one bad snippet can't wreck the whole skill message."""
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
@@ -84,43 +63,27 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
except FileNotFoundError:
|
||||
return "[inline-shell error: bash not found]"
|
||||
except RuntimeError as exc:
|
||||
# tests/conftest.py installs a live-system guard that blocks real
|
||||
# os.kill on out-of-tree PIDs. subprocess.run(timeout=...) may trip
|
||||
# that guard while trying to clean up the timed-out shell; treat that
|
||||
# as the same timeout outcome instead of surfacing the guard error.
|
||||
if "live-system guard: blocked os.kill" in str(exc):
|
||||
except Exception as exc:
|
||||
# tests/conftest.py's live-system guard may block the os.kill that
|
||||
# subprocess.run uses to clean up a timed-out shell; report the timeout.
|
||||
if isinstance(exc, RuntimeError) and "live-system guard: blocked os.kill" in str(exc):
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
return f"[inline-shell error: {exc}]"
|
||||
except Exception as exc:
|
||||
return f"[inline-shell error: {exc}]"
|
||||
|
||||
output = (completed.stdout or "").rstrip("\n")
|
||||
if not output and completed.stderr:
|
||||
output = completed.stderr.rstrip("\n")
|
||||
output = (completed.stdout or "").rstrip("\n") or (completed.stderr or "").rstrip("\n")
|
||||
if len(output) > _INLINE_SHELL_MAX_OUTPUT:
|
||||
output = output[:_INLINE_SHELL_MAX_OUTPUT] + "...[truncated]"
|
||||
return output
|
||||
|
||||
|
||||
def expand_inline_shell(
|
||||
content: str,
|
||||
skill_dir: Path | None,
|
||||
timeout: int,
|
||||
) -> str:
|
||||
"""Replace every !`cmd` snippet in ``content`` with its stdout.
|
||||
|
||||
Runs each snippet with the skill directory as CWD so relative paths in
|
||||
the snippet work the way the author expects.
|
||||
"""
|
||||
def expand_inline_shell(content: str, skill_dir: Path | None, timeout: int) -> str:
|
||||
"""Replace every !`cmd` snippet with its stdout, run with the skill dir as CWD."""
|
||||
if "!`" not in content:
|
||||
return content
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
cmd = match.group(1).strip()
|
||||
if not cmd:
|
||||
return ""
|
||||
return run_inline_shell(cmd, skill_dir, timeout)
|
||||
return run_inline_shell(cmd, skill_dir, timeout) if cmd else ""
|
||||
|
||||
return _INLINE_SHELL_RE.sub(_replace, content)
|
||||
|
||||
@@ -134,11 +97,9 @@ def preprocess_skill_content(
|
||||
"""Apply configured SKILL.md template and inline-shell preprocessing."""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
cfg = skills_cfg if isinstance(skills_cfg, dict) else load_skills_config()
|
||||
if cfg.get("template_vars", True):
|
||||
content = substitute_template_vars(content, skill_dir, session_id)
|
||||
if cfg.get("inline_shell", False):
|
||||
timeout = int(cfg.get("inline_shell_timeout", 10) or 10)
|
||||
content = expand_inline_shell(content, skill_dir, timeout)
|
||||
content = expand_inline_shell(content, skill_dir, int(cfg.get("inline_shell_timeout", 10) or 10))
|
||||
return content
|
||||
|
||||
Reference in New Issue
Block a user