NousResearch/hermes-agent

Offload blocking profiles endpoints from asyncio loop

Cluster 54523 · dashboard. Data as of

2 open items. Fix merged in #54770 on 29 Jun 2026. Check it covers your case.

The short answer

1open issue
1open PR
5items grouped
Known?
Yes. 5 related items grouped here, 0 issues already closed.
Fix event
A fix was merged to main in PR #54770. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
1 issue and 1 PR.

A bug was identified where profiles-related endpoints (list_profiles, /api/profiles/sessions) block the asyncio event loop for 10–25 seconds, starving WebSocket connections. These members address that problem through event-loop offloading, a single-pass alias map, and a skill-count cache.

Open issues1

  • Issue#54523[Bug]: Remote desktop over Tailscale: async routes block the asyncio loop 10-25s (list_profiles, /api/profiles/sessions) starving the WS; plus Chromium LNA + Electron net stalls

Open PRs1

  • PR#140923fix(dashboard): verify session tokens off the serve event loop

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #54770 perf(profiles): single-pass list_profiles alias map + skill-count cache + event-loop offload

    merged to main

    files: hermes_cli/profiles.py, hermes_cli/web_server.py, infographic/list-profiles-perf-54751/infographic.png, scripts/release.py

    Show the diff
    • hermes_cli/profiles.py
      @@ -27,6 +27,7 @@
       import stat
       import subprocess
       import sys
      +import time
       from dataclasses import dataclass
       from pathlib import Path, PurePosixPath, PureWindowsPath
       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
           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
      @@ -547,17 +572,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
      +        idx = content.find(prefix)
      +        if idx == -1:
                   continue
      -        if needle not in content:
      +        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
       
       
       # ---------------------------------------------------------------------------
      @@ -674,16 +710,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
       
       
      @@ -797,6 +885,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
      @@ -806,7 +898,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)
    • hermes_cli/web_server.py
      @@ -3430,7 +3430,7 @@ async def get_sessions(
       
       
       @app.get("/api/profiles/sessions")
      -async def get_profiles_sessions(
      +def get_profiles_sessions(
           limit: int = 20,
           offset: int = 0,
           min_messages: int = 0,
      @@ -10719,7 +10719,9 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:
       async def list_profiles_endpoint():
           from hermes_cli import profiles as profiles_mod
           try:
      -        return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]}
      +        loop = asyncio.get_running_loop()
      +        profiles = await loop.run_in_executor(None, profiles_mod.list_profiles)
      +        return {"profiles": [_profile_to_dict(p) for p in profiles]}
           except Exception:
               _log.exception("GET /api/profiles failed; falling back to profile directory scan")
               return {"profiles": _fallback_profile_dicts(profiles_mod)}
    • scripts/release.py
      @@ -1710,6 +1710,7 @@
           "[redacted:email]": "infinitycrew39",  # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
           "[redacted:email]": "huangxun375-stack",  # PR #37251 / #48894 structured OpenViking sync
           "[email protected]": "Sahil-SS9",  # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill)
      +    "[redacted:email]": "max-chen",  # PR #51194 salvage (single-pass list_profiles alias map + skill-count cache; #54751)
           # v0.17.0 additions
           "[redacted:email]": "pengyuyanITYU",  # PR #43618 (harden local file tree paths)
           "[redacted:email]": "AIalliAI",  # PR #44159 (desktop hover-reveal inset)

2 PRs closed without merging

From the close comments: merged via another change 2

  • #54560 fix: offload blocking profiles endpoints from asyncio event loop (#54523) · merged via another change → 1bb7b59, 09f10e2, f64e4f4, #54523
  • #54751 perf(profiles): speed up list_profiles (single-pass alias map + skill-count cache + event-loop offload) · merged via another change → #54770
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#54770
Closed issues
0
Attempts
2 PRs closed without merging
Fix link checked
29 Jun 2026

For your agent

The same facts, typed.