mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
perf(profiles): fix list_profiles O(N*M) wrapper rescan (6.4s -> 0.4s)
find_alias_for_profile re-scanned the whole wrapper dir (~/.local/bin) and
read_text every file for EACH profile — including large unrelated binaries
(ffmpeg etc.) read 15x over. With 16 profiles this took ~6.4s, long enough
that the desktop's per-request backend calls timed out (15s) and the sidebar
rendered '全部智能体 0 / 会话 0'.
- Add build_alias_map(): single-pass {profile -> alias} reverse map, reads
only an 8KB head slice per wrapper, skips binaries via UnicodeDecodeError.
- find_alias_for_profile now delegates to it (behavior preserved).
- Cache _count_skills by skills-dir mtime signature (+30s TTL).
list_profiles: 6.37s -> 0.84s cold / 0.44s warm. 138 profile tests pass.
This commit is contained in:
parent
bb7ff7dc30
commit
89e593749a
1 changed files with 105 additions and 13 deletions
|
|
@ -27,6 +27,7 @@ import shutil
|
|||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import List, Optional, Tuple
|
||||
|
|
@ -498,16 +499,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
|||
A custom alias (name != profile) is preferred over the profile-named wrapper
|
||||
so ``profile list``/``show`` surface the command the user actually typed.
|
||||
Results are sorted for deterministic output when several aliases match.
|
||||
|
||||
For listing ALL profiles at once, prefer :func:`build_alias_map` — calling
|
||||
this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper
|
||||
dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg
|
||||
etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts.
|
||||
"""
|
||||
return build_alias_map().get(normalize_profile_name(profile_name))
|
||||
|
||||
|
||||
# Cap how much of a wrapper file we read when reverse-looking-up its profile.
|
||||
# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``)
|
||||
# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds
|
||||
# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was
|
||||
# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and
|
||||
# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass.
|
||||
_WRAPPER_READ_LIMIT = 8192
|
||||
|
||||
|
||||
def build_alias_map() -> dict[str, str]:
|
||||
"""Single-pass reverse map ``{canonical_profile -> alias_name}``.
|
||||
|
||||
Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile)
|
||||
and reads only a small head slice of each candidate wrapper, skipping
|
||||
binaries. A custom alias (file name != profile) wins over the profile-named
|
||||
wrapper, matching ``find_alias_for_profile``'s preference; deterministic via
|
||||
sorted iteration.
|
||||
"""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
result: dict[str, str] = {}
|
||||
if not wrapper_dir.is_dir():
|
||||
return None
|
||||
canon = normalize_profile_name(profile_name)
|
||||
return result
|
||||
is_windows = sys.platform == "win32"
|
||||
needle = f"hermes -p {canon}"
|
||||
prefix = "hermes -p "
|
||||
|
||||
custom: Optional[str] = None
|
||||
profile_named: Optional[str] = None
|
||||
for entry in sorted(wrapper_dir.iterdir()):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
|
|
@ -517,17 +542,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
|||
if not is_windows and entry.suffix:
|
||||
continue
|
||||
try:
|
||||
content = entry.read_text()
|
||||
with open(entry, "r", encoding="utf-8", errors="strict") as f:
|
||||
content = f.read(_WRAPPER_READ_LIMIT)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
|
||||
continue
|
||||
if needle not in content:
|
||||
idx = content.find(prefix)
|
||||
if idx == -1:
|
||||
continue
|
||||
rest = content[idx + len(prefix):]
|
||||
# Profile id is the first whitespace-delimited token after the flag.
|
||||
canon = rest.split(None, 1)[0].strip() if rest.strip() else ""
|
||||
if not canon:
|
||||
continue
|
||||
canon = normalize_profile_name(canon)
|
||||
alias = entry.stem if is_windows else entry.name
|
||||
# Custom alias (name != profile) preferred; otherwise keep the
|
||||
# profile-named wrapper. Don't overwrite a custom alias already found.
|
||||
if alias == canon:
|
||||
profile_named = alias
|
||||
elif custom is None:
|
||||
custom = alias
|
||||
return custom if custom is not None else profile_named
|
||||
result.setdefault(canon, alias)
|
||||
else:
|
||||
result[canon] = alias
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -620,16 +656,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")``
|
||||
# recurses the entire skill tree (each skill carries references/scripts/assets
|
||||
# sub-trees); the default profile alone has ~270 skills, and ``list_profiles``
|
||||
# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long
|
||||
# enough that the desktop's per-request backend calls time out and the sidebar
|
||||
# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated
|
||||
# when the dir tree's signature (skills_dir + immediate category dirs mtimes)
|
||||
# changes (catches skill add/remove) or after a short TTL (catches deep edits).
|
||||
_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {}
|
||||
_SKILL_COUNT_TTL_SECONDS = 30.0
|
||||
|
||||
|
||||
def _skills_dir_signature(skills_dir: Path) -> float:
|
||||
"""Cheap change-signature for a skills tree.
|
||||
|
||||
Max mtime of ``skills_dir`` and its immediate children (category dirs).
|
||||
Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a
|
||||
skill inside a category bumps that category dir's mtime. One ``scandir``
|
||||
(not a recursive walk) keeps this O(#categories), not O(#files).
|
||||
"""
|
||||
try:
|
||||
sig = skills_dir.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
try:
|
||||
with os.scandir(skills_dir) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
m = entry.stat(follow_symlinks=False).st_mtime
|
||||
if m > sig:
|
||||
sig = m
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return sig
|
||||
|
||||
|
||||
def _count_skills(profile_dir: Path) -> int:
|
||||
"""Count installed skills in a profile."""
|
||||
"""Count installed skills in a profile (cached by skills-dir signature)."""
|
||||
skills_dir = profile_dir / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return 0
|
||||
|
||||
key = str(skills_dir)
|
||||
signature = _skills_dir_signature(skills_dir)
|
||||
now = time.time()
|
||||
cached = _SKILL_COUNT_CACHE.get(key)
|
||||
if (
|
||||
cached is not None
|
||||
and cached[0] == signature
|
||||
and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS
|
||||
):
|
||||
return cached[2]
|
||||
|
||||
count = 0
|
||||
for md in skills_dir.rglob("SKILL.md"):
|
||||
if is_excluded_skill_path(md):
|
||||
continue
|
||||
count += 1
|
||||
_SKILL_COUNT_CACHE[key] = (signature, now, count)
|
||||
return count
|
||||
|
||||
|
||||
|
|
@ -743,6 +831,10 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
# Named profiles
|
||||
profiles_root = _get_profiles_root()
|
||||
if profiles_root.is_dir():
|
||||
# Build the {profile -> alias} map ONCE here instead of calling
|
||||
# find_alias_for_profile() per profile (which re-scanned the whole
|
||||
# wrapper dir each time — O(N*M), the dominant cost in this function).
|
||||
alias_map = build_alias_map()
|
||||
for entry in sorted(profiles_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
|
@ -752,7 +844,7 @@ def list_profiles() -> List[ProfileInfo]:
|
|||
if not _PROFILE_ID_RE.match(name):
|
||||
continue
|
||||
model, provider = _read_config_model(entry)
|
||||
alias_name = find_alias_for_profile(name)
|
||||
alias_name = alias_map.get(normalize_profile_name(name))
|
||||
if alias_name:
|
||||
is_windows = sys.platform == "win32"
|
||||
alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue