perf(skills): cache config.yaml reads with mtime/size invalidation - #16202
perf(skills): cache config.yaml reads with mtime/size invalidation#16202briandevans wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an in-process cache for ~/.hermes/config.yaml parsing in agent/skill_utils.py so repeated calls to skill config helpers don’t re-read/re-parse YAML on every build_skills_system_prompt() invocation.
Changes:
- Introduce
_read_config_cached()with(st_mtime_ns, st_size)invalidation and bounded in-memory storage. - Route
get_disabled_skill_names()andget_external_skills_dirs()through the cached config reader. - Add focused pytest coverage for cache hits, invalidation, missing file behavior, and malformed YAML handling.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
agent/skill_utils.py |
Implements a lock-protected, bounded cache for config.yaml parsing and updates key helpers to use it. |
tests/agent/test_skill_utils_config_cache.py |
Adds tests to pin cache behavior (deduping parses, invalidation on change, and safe fallback cases). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
build_skills_system_prompt() calls get_disabled_skill_names() and get_external_skills_dirs() on every invocation — including before the in-process LRU prompt cache lookup — so each call previously re-read and re-parsed ~/.hermes/config.yaml twice. In gateway mode that's two file reads + two YAML parses per session. Add a small in-process cache keyed by config path, invalidated by (st_mtime_ns, st_size). User edits still take effect immediately because any write changes mtime (and usually size). Bench on macOS APFS: ~6.3x faster on the cached path (51us -> 8us per call). No behaviour change — same return values, just memoized.
Audit follow-up to the prior commit:
* Add ``st_ino`` to the cache signature. ``atomic_yaml_write`` (used by
gateway/CLI runtime config edits) writes a temp file and ``os.replace``s
it onto config.yaml — the new file always has a fresh inode. On
filesystems with coarse mtime resolution, two same-size writes within
one tick would otherwise share the same (mtime, size) tuple; including
the inode makes the invalidation bulletproof against atomic replacement.
* Strengthen the read-only contract docstring on the returned dict and
document why callers (``get_disabled_skill_names`` /
``get_external_skills_dirs``) are safe — both return fresh sets/lists.
* Expand the test suite from 4 to 9 cases:
- atomic_yaml_write round-trip invalidates the cache (the actual
runtime path used by gateway/CLI to edit config.yaml)
- mutating the returned set never corrupts subsequent reads
- mutating the returned list never corrupts subsequent reads
- 8 concurrent reader threads × 50 reads each — no corruption, no
races, all results identical
- cache size stays bounded (≤ MAX_ENTRIES) under many distinct
HERMES_HOME paths
eb77e46 to
66bbe1e
Compare
|
Closing — this fix has effectively landed via #22138 (commit |
Summary
build_skills_system_prompt()callsget_disabled_skill_names()andget_external_skills_dirs()on every invocation — and both run before the in-process LRU prompt cache lookup at agent/prompt_builder.py:663. Each previously re-read and re-parsed~/.hermes/config.yaml, so building (or even cache-hitting) the skills prompt did two file reads + two YAML parses per call. In gateway mode that's per session.This PR adds a tiny in-process cache around the config-file read inside
agent/skill_utils.py, keyed by config path and invalidated by(st_mtime_ns, st_size, st_ino).Benchmark on macOS APFS, parsing a representative
skills.disabledconfig 5000×:That's ~37 µs trimmed off every
build_skills_system_prompt()cache hit and ~74 µs off every cold cache miss — multiplied by every gateway session and everyskills_listtool call.Audit / why no downside
mtime_ns→ cache invalidates.atomic_yaml_write(used bygateway/run.pyandhermes_cli/config.pyto mutate config.yaml at runtime) does temp-file +os.replace, which always assigns a fresh inode.st_inoin the signature catches this even on filesystems with coarse mtime resolution.threading.Lock. Verified with 8 threads × 50 reads, zero corruption.get_disabled_skill_namesreturns a fresh set built by_normalize_string_set;get_external_skills_dirsreturns a fresh list. Verified with mutation tests._read_config_cachedis a private helper; both in-process callers inskill_utils.pyare read-only. Documented as read-only contract.{}, cached withsig=None. Self-corrects when file is created (different sig → re-read).Exceptioncaught, returns{}, cached. Logged at DEBUG level (existing behaviour preserved).monkeypatch.setenv("HERMES_HOME", ...)tmp_path⇒ unique config path ⇒ unique cache key. Test fixture also clears the cache before/after each test.tools/skills_tool.py,tools/credential_files.py,hermes_cli/skills_hub.py,hermes_cli/commands.py,agent/skill_commands.py,gateway/run.py,agent/prompt_builder.py) are read-only. None edit config.yaml then immediately re-read inside one call frame; the few that write follow the "takes effect on next message" pattern, by which point the cache has already invalidated via stat-sig change.Test plan
pytest tests/agent/test_skill_utils_config_cache.py -v— 9 cases, all passatomic_yaml_writeround-trip invalidates cachetests/agent/test_external_skills.py tests/agent/test_prompt_builder.py tests/agent/test_skill_commands.py tests/hermes_cli/test_skills_config.py tests/hermes_cli/test_skills_hub.py tests/tools/test_skill_manager_tool.py tests/tools/test_skills_sync.py tests/tools/test_skill_view_path_check.py tests/tools/test_skills_guard.py tests/tools/test_skills_hub_clawhub.pyWhat changed
agent/skill_utils.py: new_read_config_cached()+_clear_config_cache()helpers;get_disabled_skill_namesandget_external_skills_dirsroute through themtests/agent/test_skill_utils_config_cache.py: 9 tests pinning hit/miss/invalidation/concurrency/mutation-safety/bounded-memory behaviour