mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
`hermes tools` -> "All Platforms" took ~14s to render the checklist because building the toolset labels called `get_nous_auth_status()` ~31x transitively (`_toolset_has_keys` -> `_visible_providers` -> `get_nous_subscription_features` -> `managed_nous_tools_enabled`). Each call did a synchronous OAuth refresh POST to portal.nousresearch.com (~350ms even on the failure path), so one menu paint burned >13s of HTTP and 31 single-use Nous refresh tokens. Secondary hot spot: every `get_env_value()` re-read and re-sanitised the entire .env file. 116 reads with O(lines x known-keys) scanning added ~300ms of CPU per render. Fix is two process-level caches, both mtime-keyed so login/logout/edit invalidate naturally: * `hermes_cli/auth.py`: memoise `get_nous_auth_status()` for 15s keyed on auth.json mtime. Splits `_compute_nous_auth_status()` as the uncached impl. Adds `invalidate_nous_auth_status_cache()`. * `hermes_cli/config.py`: memoise `load_env()` keyed on .env (path, mtime, size). Adds `invalidate_env_cache()`, wired into `save_env_value`, `remove_env_value`, and the sanitize-on-load writer so writers don't return stale dicts on same-second writes. Before/after on Teknium's box (real HERMES_HOME, no Nous login): * "All Platforms" cold path: ~13,874ms -> ~691ms label-build * Warm re-open within the same process: ~122ms -> ~17ms Side benefit: stops burning a Nous refresh token on every menu paint, which was risking the portal's reuse-detection revocation logic.
193 lines
6.6 KiB
Python
193 lines
6.6 KiB
Python
"""Tests for the load_env() process-level cache.
|
|
|
|
The cache exists to keep `hermes tools` → "All Platforms" fast: every
|
|
`get_env_value()` lookup used to re-read and re-sanitise the entire
|
|
.env file, racking up hundreds of ms across one menu render. The
|
|
cache is keyed on (path, mtime, size); writers (save_env_value /
|
|
remove_env_value / sanitise_env_file) call invalidate_env_cache().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
def _write_env(path: Path, contents: str) -> None:
|
|
path.write_text(contents, encoding="utf-8")
|
|
|
|
|
|
def test_load_env_caches_on_repeat_calls():
|
|
"""Repeated load_env() calls on the same file return the cached dict."""
|
|
from hermes_cli.config import invalidate_env_cache, load_env
|
|
|
|
invalidate_env_cache()
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("OPENAI_API_KEY=sk-first\n")
|
|
env_path = Path(f.name)
|
|
|
|
try:
|
|
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
|
first = load_env()
|
|
# Even if a writer outside our cache mutates the file, an
|
|
# mtime/size match means the cache still wins. We simulate that
|
|
# by writing identical bytes back — sanity check that the cache
|
|
# is keyed structurally, not on a counter.
|
|
second = load_env()
|
|
|
|
assert first == second
|
|
assert first.get("OPENAI_API_KEY") == "sk-first"
|
|
finally:
|
|
env_path.unlink(missing_ok=True)
|
|
invalidate_env_cache()
|
|
|
|
|
|
def test_load_env_invalidates_on_mtime_bump():
|
|
"""Editing the file (mtime changes) invalidates the cache."""
|
|
from hermes_cli.config import invalidate_env_cache, load_env
|
|
|
|
invalidate_env_cache()
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("OPENAI_API_KEY=sk-old\n")
|
|
env_path = Path(f.name)
|
|
|
|
try:
|
|
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
|
first = load_env()
|
|
assert first.get("OPENAI_API_KEY") == "sk-old"
|
|
|
|
# Rewrite file with new contents and bump mtime to make sure
|
|
# the FS records the change even on coarse-mtime filesystems.
|
|
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
|
|
future = env_path.stat().st_mtime + 5.0
|
|
os.utime(env_path, (future, future))
|
|
|
|
second = load_env()
|
|
assert second.get("OPENAI_API_KEY") == "sk-new", (
|
|
"load_env() returned stale value after file change"
|
|
)
|
|
finally:
|
|
env_path.unlink(missing_ok=True)
|
|
invalidate_env_cache()
|
|
|
|
|
|
def test_invalidate_env_cache_forces_reread():
|
|
"""invalidate_env_cache() forces the next load_env() to hit the disk.
|
|
|
|
This is the belt-and-braces knob for writers (save_env_value, etc.)
|
|
on filesystems where mtime resolution might miss a same-second write.
|
|
"""
|
|
from hermes_cli.config import invalidate_env_cache, load_env
|
|
|
|
invalidate_env_cache()
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("OPENAI_API_KEY=sk-old\n")
|
|
env_path = Path(f.name)
|
|
|
|
try:
|
|
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
|
assert load_env().get("OPENAI_API_KEY") == "sk-old"
|
|
|
|
# Rewrite WITHOUT bumping mtime — simulates same-second write.
|
|
mtime_before = env_path.stat().st_mtime
|
|
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
|
|
os.utime(env_path, (mtime_before, mtime_before))
|
|
|
|
# Without invalidation, cache hit might return stale.
|
|
invalidate_env_cache()
|
|
|
|
assert load_env().get("OPENAI_API_KEY") == "sk-new"
|
|
finally:
|
|
env_path.unlink(missing_ok=True)
|
|
invalidate_env_cache()
|
|
|
|
|
|
def test_save_env_value_invalidates_cache(tmp_path, monkeypatch):
|
|
"""save_env_value() invalidates the cache so subsequent reads see the update."""
|
|
from hermes_cli import config as config_mod
|
|
from hermes_cli.config import invalidate_env_cache, load_env, save_env_value
|
|
|
|
invalidate_env_cache()
|
|
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("EXISTING_KEY=old\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
|
|
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
|
|
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
|
|
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
|
|
|
|
try:
|
|
# Prime the cache.
|
|
first = load_env()
|
|
assert first.get("EXISTING_KEY") == "old"
|
|
|
|
save_env_value("NEW_KEY", "shiny")
|
|
|
|
# Same-second writes on coarse-mtime filesystems would normally
|
|
# let stale cache survive; invalidate_env_cache() inside the
|
|
# writer makes the next read see the new key.
|
|
result = load_env()
|
|
assert result.get("NEW_KEY") == "shiny"
|
|
assert result.get("EXISTING_KEY") == "old"
|
|
finally:
|
|
monkeypatch.delenv("NEW_KEY", raising=False)
|
|
invalidate_env_cache()
|
|
|
|
|
|
def test_remove_env_value_invalidates_cache(tmp_path, monkeypatch):
|
|
"""remove_env_value() invalidates the cache so the removed key disappears."""
|
|
from hermes_cli import config as config_mod
|
|
from hermes_cli.config import (
|
|
invalidate_env_cache,
|
|
load_env,
|
|
remove_env_value,
|
|
save_env_value,
|
|
)
|
|
|
|
invalidate_env_cache()
|
|
|
|
env_path = tmp_path / ".env"
|
|
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
|
|
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
|
|
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
|
|
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
|
|
|
|
save_env_value("DOOMED_KEY", "value")
|
|
assert load_env().get("DOOMED_KEY") == "value"
|
|
|
|
try:
|
|
removed = remove_env_value("DOOMED_KEY")
|
|
assert removed is True
|
|
assert "DOOMED_KEY" not in load_env()
|
|
finally:
|
|
monkeypatch.delenv("DOOMED_KEY", raising=False)
|
|
invalidate_env_cache()
|
|
|
|
|
|
def test_load_env_handles_missing_file():
|
|
"""A nonexistent .env returns {} and caches the empty result."""
|
|
from hermes_cli.config import invalidate_env_cache, load_env
|
|
|
|
invalidate_env_cache()
|
|
|
|
nonexistent = Path(tempfile.gettempdir()) / "hermes-test-no-such-env-xyz123.env"
|
|
nonexistent.unlink(missing_ok=True)
|
|
|
|
try:
|
|
with patch("hermes_cli.config.get_env_path", return_value=nonexistent):
|
|
assert load_env() == {}
|
|
assert load_env() == {} # cached
|
|
finally:
|
|
invalidate_env_cache()
|