perf(ttft): cache skills prompt with shared skill_utils module (salvage #3366) (#3421)

Two-layer caching for build_skills_system_prompt():
  1. In-process LRU (OrderedDict, max 8) — same-process: 546ms → <1ms
  2. Disk snapshot (.skills_prompt_snapshot.json) — cold start: 297ms → 103ms

Key improvements over original PR #3366:
- Extract shared logic into agent/skill_utils.py (parse_frontmatter,
  skill_matches_platform, get_disabled_skill_names, extract_skill_conditions,
  extract_skill_description, iter_skill_index_files)
- tools/skills_tool.py delegates to shared module — zero code duplication
- Proper LRU eviction via OrderedDict.move_to_end + popitem(last=False)
- Cache invalidation on all skill mutation paths:
  - skill_manage tool (in-conversation writes)
  - hermes skills install (CLI hub)
  - hermes skills uninstall (CLI hub)
  - Automatic via mtime/size manifest on cold start

prompt_builder.py no longer imports tools.skills_tool (avoids pulling
in the entire tool registry chain at prompt build time).

6301 tests pass, 0 failures.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
This commit is contained in:
Teknium
2026-03-27 10:54:02 -07:00
committed by GitHub
parent cc4514076b
commit 5127567d5d
8 changed files with 527 additions and 196 deletions

View File

@@ -547,6 +547,13 @@ def skill_manage(
else:
result = {"success": False, "error": f"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file"}
if result.get("success"):
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
return json.dumps(result, ensure_ascii=False)

View File

@@ -120,28 +120,11 @@ def set_secret_capture_callback(callback) -> None:
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Check if a skill is compatible with the current OS platform.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
Valid values: ``macos``, ``linux``, ``windows``.
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Delegates to ``agent.skill_utils.skill_matches_platform`` — kept here
as a public re-export so existing callers don't need updating.
"""
platforms = frontmatter.get("platforms")
if not platforms:
return True # No restriction → loads everywhere
if not isinstance(platforms, list):
platforms = [platforms]
current = sys.platform
for p in platforms:
mapped = _PLATFORM_MAP.get(str(p).lower().strip(), str(p).lower().strip())
if current.startswith(mapped):
return True
return False
from agent.skill_utils import skill_matches_platform as _impl
return _impl(frontmatter)
def _normalize_prerequisite_values(value: Any) -> List[str]:
@@ -419,40 +402,13 @@ def check_skills_requirements() -> bool:
def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content.
Delegates to ``agent.skill_utils.parse_frontmatter`` — kept here
as a public re-export so existing callers don't need updating.
"""
Parse YAML frontmatter from markdown content.
Uses yaml.safe_load for full YAML support (nested metadata, lists, etc.)
with a fallback to simple key:value splitting for robustness.
Args:
content: Full markdown file content
Returns:
Tuple of (frontmatter dict, remaining content)
"""
frontmatter = {}
body = content
if content.startswith("---"):
end_match = re.search(r"\n---\s*\n", content[3:])
if end_match:
yaml_content = content[3 : end_match.start() + 3]
body = content[end_match.end() + 3 :]
try:
parsed = yaml.safe_load(yaml_content)
if isinstance(parsed, dict):
frontmatter = parsed
# yaml.safe_load returns None for empty frontmatter
except yaml.YAMLError:
# Fallback: simple key:value parsing for malformed YAML
for line in yaml_content.strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip()
return frontmatter, body
from agent.skill_utils import parse_frontmatter
return parse_frontmatter(content)
def _get_category_from_path(skill_path: Path) -> Optional[str]:
@@ -516,24 +472,13 @@ def _parse_tags(tags_value) -> List[str]:
def _get_disabled_skill_names() -> Set[str]:
"""Load disabled skill names from config (once per call).
"""Load disabled skill names from config.
Resolves platform from ``HERMES_PLATFORM`` env var, falls back to
the global disabled list.
Delegates to ``agent.skill_utils.get_disabled_skill_names`` — kept here
as a public re-export so existing callers don't need updating.
"""
import os
try:
from hermes_cli.config import load_config
config = load_config()
skills_cfg = config.get("skills", {})
resolved_platform = os.getenv("HERMES_PLATFORM")
if resolved_platform:
platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform)
if platform_disabled is not None:
return set(platform_disabled)
return set(skills_cfg.get("disabled", []))
except Exception:
return set()
from agent.skill_utils import get_disabled_skill_names
return get_disabled_skill_names()
def _is_skill_disabled(name: str, platform: str = None) -> bool: