feat(skills): add skill config interface + llm-wiki skill (#5635)
Skills can now declare config.yaml settings via metadata.hermes.config in their SKILL.md frontmatter. Values are stored under skills.config.* namespace, prompted during hermes config migrate, shown in hermes config show, and injected into the skill context at load time. Also adds the llm-wiki skill (Karpathy's LLM Wiki pattern) as the first skill to use the new config interface, declaring wiki.path. Skill config interface (new): - agent/skill_utils.py: extract_skill_config_vars(), discover_all_skill_config_vars(), resolve_skill_config_values(), SKILL_CONFIG_PREFIX - agent/skill_commands.py: _inject_skill_config() injects resolved values into skill messages as [Skill config: ...] block - hermes_cli/config.py: get_missing_skill_config_vars(), skill config prompting in migrate_config(), Skill Settings in show_config() LLM Wiki skill (skills/research/llm-wiki/SKILL.md): - Three-layer architecture (raw sources, wiki pages, schema) - Three operations (ingest, query, lint) - Session orientation, page thresholds, tag taxonomy, update policy, scaling guidance, log rotation, archiving workflow Docs: creating-skills.md, configuration.md, skills.md, skills-catalog.md Closes #5100
This commit is contained in:
@@ -79,6 +79,45 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
|
||||
return loaded_skill, skill_dir, skill_name
|
||||
|
||||
|
||||
def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None:
|
||||
"""Resolve and inject skill-declared config values into the message parts.
|
||||
|
||||
If the loaded skill's frontmatter declares ``metadata.hermes.config``
|
||||
entries, their current values (from config.yaml or defaults) are appended
|
||||
as a ``[Skill config: ...]`` block so the agent knows the configured values
|
||||
without needing to read config.yaml itself.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_utils import (
|
||||
extract_skill_config_vars,
|
||||
parse_frontmatter,
|
||||
resolve_skill_config_values,
|
||||
)
|
||||
|
||||
# The loaded_skill dict contains the raw content which includes frontmatter
|
||||
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 = ["", "[Skill config (from ~/.hermes/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 # Non-critical — skill still loads without config injection
|
||||
|
||||
|
||||
def _build_skill_message(
|
||||
loaded_skill: dict[str, Any],
|
||||
skill_dir: Path | None,
|
||||
@@ -93,6 +132,9 @@ def _build_skill_message(
|
||||
|
||||
parts = [activation_note, "", content.strip()]
|
||||
|
||||
# ── Inject resolved skill config values ──
|
||||
_inject_skill_config(loaded_skill, parts)
|
||||
|
||||
if loaded_skill.get("setup_skipped"):
|
||||
parts.extend(
|
||||
[
|
||||
|
||||
@@ -254,6 +254,163 @@ def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:
|
||||
}
|
||||
|
||||
|
||||
# ── Skill config extraction ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_skill_config_vars(frontmatter: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Extract config variable declarations from parsed frontmatter.
|
||||
|
||||
Skills declare config.yaml settings they need via::
|
||||
|
||||
metadata:
|
||||
hermes:
|
||||
config:
|
||||
- key: wiki.path
|
||||
description: Path to the LLM Wiki knowledge base directory
|
||||
default: "~/wiki"
|
||||
prompt: Wiki directory path
|
||||
|
||||
Returns a list of dicts with keys: ``key``, ``description``, ``default``,
|
||||
``prompt``. Invalid or incomplete entries are silently skipped.
|
||||
"""
|
||||
metadata = frontmatter.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
hermes = metadata.get("hermes")
|
||||
if not isinstance(hermes, dict):
|
||||
return []
|
||||
raw = hermes.get("config")
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("key", "")).strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
# Must have at least key and description
|
||||
desc = str(item.get("description", "")).strip()
|
||||
if not desc:
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"key": key,
|
||||
"description": desc,
|
||||
}
|
||||
default = item.get("default")
|
||||
if default is not None:
|
||||
entry["default"] = default
|
||||
prompt_text = item.get("prompt")
|
||||
if isinstance(prompt_text, str) and prompt_text.strip():
|
||||
entry["prompt"] = prompt_text.strip()
|
||||
else:
|
||||
entry["prompt"] = desc
|
||||
seen.add(key)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def discover_all_skill_config_vars() -> List[Dict[str, Any]]:
|
||||
"""Scan all enabled skills and collect their config variable declarations.
|
||||
|
||||
Walks every skills directory, parses each SKILL.md frontmatter, and returns
|
||||
a deduplicated list of config var dicts. Each dict also includes a
|
||||
``skill`` key with the skill name for attribution.
|
||||
|
||||
Disabled and platform-incompatible skills are excluded.
|
||||
"""
|
||||
all_vars: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
|
||||
disabled = get_disabled_skill_names()
|
||||
for skills_dir in get_all_skills_dirs():
|
||||
if not skills_dir.is_dir():
|
||||
continue
|
||||
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
|
||||
try:
|
||||
raw = skill_file.read_text(encoding="utf-8")
|
||||
frontmatter, _ = parse_frontmatter(raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
skill_name = frontmatter.get("name") or skill_file.parent.name
|
||||
if str(skill_name) in disabled:
|
||||
continue
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
|
||||
config_vars = extract_skill_config_vars(frontmatter)
|
||||
for var in config_vars:
|
||||
if var["key"] not in seen_keys:
|
||||
var["skill"] = str(skill_name)
|
||||
all_vars.append(var)
|
||||
seen_keys.add(var["key"])
|
||||
|
||||
return all_vars
|
||||
|
||||
|
||||
# Storage prefix: all skill config vars are stored under skills.config.*
|
||||
# in config.yaml. Skill authors declare logical keys (e.g. "wiki.path");
|
||||
# the system adds this prefix for storage and strips it for display.
|
||||
SKILL_CONFIG_PREFIX = "skills.config"
|
||||
|
||||
|
||||
def _resolve_dotpath(config: Dict[str, Any], dotted_key: str):
|
||||
"""Walk a nested dict following a dotted key. Returns None if any part is missing."""
|
||||
parts = dotted_key.split(".")
|
||||
current = config
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def resolve_skill_config_values(
|
||||
config_vars: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve current values for skill config vars from config.yaml.
|
||||
|
||||
Skill config is stored under ``skills.config.<key>`` in config.yaml.
|
||||
Returns a dict mapping **logical** keys (as declared by skills) to their
|
||||
current values (or the declared default if the key isn't set).
|
||||
Path values are expanded via ``os.path.expanduser``.
|
||||
"""
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
config: Dict[str, Any] = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved: Dict[str, Any] = {}
|
||||
for var in config_vars:
|
||||
logical_key = var["key"]
|
||||
storage_key = f"{SKILL_CONFIG_PREFIX}.{logical_key}"
|
||||
value = _resolve_dotpath(config, storage_key)
|
||||
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
value = var.get("default", "")
|
||||
|
||||
# Expand ~ in path-like values
|
||||
if isinstance(value, str) and ("~" in value or "${" in value):
|
||||
value = os.path.expanduser(os.path.expandvars(value))
|
||||
|
||||
resolved[logical_key] = value
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
# ── Description extraction ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user