mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +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.
(cherry picked from commit 89e593749a)
This commit is contained in:
parent
2f5950a83a
commit
d5eee133eb
1 changed files with 105 additions and 13 deletions
|
|
@ -27,6 +27,7 @@ import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
||||||
A custom alias (name != profile) is preferred over the profile-named wrapper
|
A custom alias (name != profile) is preferred over the profile-named wrapper
|
||||||
so ``profile list``/``show`` surface the command the user actually typed.
|
so ``profile list``/``show`` surface the command the user actually typed.
|
||||||
Results are sorted for deterministic output when several aliases match.
|
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()
|
wrapper_dir = _get_wrapper_dir()
|
||||||
|
result: dict[str, str] = {}
|
||||||
if not wrapper_dir.is_dir():
|
if not wrapper_dir.is_dir():
|
||||||
return None
|
return result
|
||||||
canon = normalize_profile_name(profile_name)
|
|
||||||
is_windows = sys.platform == "win32"
|
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()):
|
for entry in sorted(wrapper_dir.iterdir()):
|
||||||
if not entry.is_file():
|
if not entry.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
||||||
if not is_windows and entry.suffix:
|
if not is_windows and entry.suffix:
|
||||||
continue
|
continue
|
||||||
try:
|
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):
|
except (OSError, UnicodeDecodeError):
|
||||||
|
# UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
|
||||||
continue
|
continue
|
||||||
if needle not in content:
|
idx = content.find(prefix)
|
||||||
|
if idx == -1:
|
||||||
continue
|
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
|
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:
|
if alias == canon:
|
||||||
profile_named = alias
|
result.setdefault(canon, alias)
|
||||||
elif custom is None:
|
else:
|
||||||
custom = alias
|
result[canon] = alias
|
||||||
return custom if custom is not None else profile_named
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:
|
||||||
return False
|
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:
|
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"
|
skills_dir = profile_dir / "skills"
|
||||||
if not skills_dir.is_dir():
|
if not skills_dir.is_dir():
|
||||||
return 0
|
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
|
count = 0
|
||||||
for md in skills_dir.rglob("SKILL.md"):
|
for md in skills_dir.rglob("SKILL.md"):
|
||||||
if is_excluded_skill_path(md):
|
if is_excluded_skill_path(md):
|
||||||
continue
|
continue
|
||||||
count += 1
|
count += 1
|
||||||
|
_SKILL_COUNT_CACHE[key] = (signature, now, count)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]:
|
||||||
# Named profiles
|
# Named profiles
|
||||||
profiles_root = _get_profiles_root()
|
profiles_root = _get_profiles_root()
|
||||||
if profiles_root.is_dir():
|
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()):
|
for entry in sorted(profiles_root.iterdir()):
|
||||||
if not entry.is_dir():
|
if not entry.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|
@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]:
|
||||||
if not _PROFILE_ID_RE.match(name):
|
if not _PROFILE_ID_RE.match(name):
|
||||||
continue
|
continue
|
||||||
model, provider = _read_config_model(entry)
|
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:
|
if alias_name:
|
||||||
is_windows = sys.platform == "win32"
|
is_windows = sys.platform == "win32"
|
||||||
alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)
|
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