perf(cli): sub-400ms warm startup — probe-mode check_fns, lazy MCP SDK, banner snapshot, parallel worktree add

Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:

- aux availability probes built REAL OpenAI/httpx clients (openai import
  ~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
  returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
  construction) at module import even with zero MCP servers configured.
  SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
  find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
  defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
  launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
  (config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
  with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
  network) per launch; the tool-search gate now prefers the on-disk
  context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
  (~85ms) per SessionDB(); the reference parse is now disk-memoized by
  DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
  to a daemon thread; plugin discovery starts in the background and every
  synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
  test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
  building all ~40 subcommand parsers (bails to full dispatch on anything
  else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
  overlaps HermesCLI construction; --skills preload runs in the background
  and is folded in at agent init (finalize_preloaded_skills, same
  fail-loud contract for fully-unknown skill lists); stale-worktree prune
  moved off the banner path.

Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.
This commit is contained in:
Teknium 2026-08-10 02:04:26 -07:00
parent 03fa32c92d
commit 55f9e472a0
17 changed files with 1508 additions and 245 deletions

View File

@ -111,6 +111,55 @@ class _OpenAIProxy:
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
# ── Availability probe mode ───────────────────────────────────────────────
# check_fns (tool gating) only need to know whether a client is RESOLVABLE —
# credentials present, provider routable. Building a real SDK client for that
# answer forces the `openai` import (~0.3s) plus httpx/SSL-context setup on
# the CLI startup path, twice (vision + browser_vision), for an object that
# is immediately discarded. Inside `aux_probe_mode()` the client constructors
# return a lightweight stub instead; resolution POLICY (which provider wins,
# credential lookup, fallback order) is unchanged and stays single-owner.
# Stubs are never cached (see _store_cached_client), so runtime callers can
# never receive one.
_aux_probe_state = threading.local()
class _AuxProbeClientStub:
"""Non-functional placeholder returned while `aux_probe_mode` is active."""
__slots__ = ("api_key", "base_url")
def __init__(self, api_key: str = "", base_url: str = "") -> None:
self.api_key = api_key
self.base_url = base_url
def __getattr__(self, name: str) -> Any:
# Loud failure if a probe stub ever leaks into a runtime call path
# (it must not — stubs are cache-excluded and probe-scoped).
raise RuntimeError(
f"_AuxProbeClientStub used as a real client (attribute {name!r}); "
"aux_probe_mode is for availability checks only"
)
def __repr__(self) -> str:
return "<aux availability-probe client stub>"
def _aux_probe_active() -> bool:
return bool(getattr(_aux_probe_state, "active", False))
@contextlib.contextmanager
def aux_probe_mode():
"""Resolve provider availability without constructing real SDK clients."""
prev = getattr(_aux_probe_state, "active", False)
_aux_probe_state.active = True
try:
yield
finally:
_aux_probe_state.active = prev
from agent.credential_pool import load_pool
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from hermes_cli.config import get_hermes_home
@ -208,6 +257,10 @@ def _openai_http_client_kwargs(
return {"http_client": client}
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
if _aux_probe_active():
# Availability probe: credentials/base_url resolved — that is the
# answer. Skip the openai import + httpx/SSL construction entirely.
return _AuxProbeClientStub(api_key=api_key, base_url=base_url)
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
# Hermes owns auxiliary retry + provider/model fallback policy (the
# same-provider transient retry in call_llm plus the except-chain
@ -2179,6 +2232,11 @@ def _maybe_wrap_anthropic(
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
"""
# Already wrapped — don't double-wrap.
if isinstance(client_obj, _AuxProbeClientStub):
# Availability probe: transport correction is irrelevant — the stub
# only signals resolvability. Skipping also avoids importing adapter
# modules (copilot_acp_client pulls in openai.types) on the probe path.
return client_obj
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
@ -2731,26 +2789,30 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
# _NOUS_MODEL (google/gemini-3-flash-preview) when the Portal is unreachable
# or returns a null recommendation for this task type.
model = _NOUS_MODEL
try:
from hermes_cli.models import get_nous_recommended_aux_model
recommended = get_nous_recommended_aux_model(vision=vision)
if recommended:
model = recommended
if not _aux_probe_active():
# Availability probes skip the recommended-model lookup: the exact
# model is irrelevant to "is Nous resolvable?", and the Portal
# recommended-models fetch below can hit the network.
try:
from hermes_cli.models import get_nous_recommended_aux_model
recommended = get_nous_recommended_aux_model(vision=vision)
if recommended:
model = recommended
logger.debug(
"Auxiliary/%s: using Portal-recommended model %s",
"vision" if vision else "text", model,
)
else:
logger.debug(
"Auxiliary/%s: no Portal recommendation, falling back to %s",
"vision" if vision else "text", model,
)
except Exception as exc:
logger.debug(
"Auxiliary/%s: using Portal-recommended model %s",
"vision" if vision else "text", model,
"Auxiliary/%s: recommended-models lookup failed (%s); "
"falling back to %s",
"vision" if vision else "text", exc, model,
)
else:
logger.debug(
"Auxiliary/%s: no Portal recommendation, falling back to %s",
"vision" if vision else "text", model,
)
except Exception as exc:
logger.debug(
"Auxiliary/%s: recommended-models lookup failed (%s); "
"falling back to %s",
"vision" if vision else "text", exc, model,
)
if runtime is not None:
api_key, base_url = runtime
@ -3696,6 +3758,10 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
from agent.anthropic_adapter import _is_oauth_token
is_oauth = _is_oauth_token(token)
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
if _aux_probe_active():
# Availability probe — token + SDK adapter import resolved; skip
# real client construction.
return _AuxProbeClientStub(api_key="", base_url=base_url), model
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
@ -5806,6 +5872,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
"""
from openai import AsyncOpenAI
if isinstance(sync_client, _AuxProbeClientStub):
return sync_client, model
if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
@ -7233,6 +7301,10 @@ def _client_cache_key(
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
if isinstance(client, _AuxProbeClientStub):
# Probe stubs must never enter the cache — a runtime caller would
# receive a non-functional client on the next cache hit.
return
with _client_cache_lock:
old_entry = _client_cache.get(cache_key)
if old_entry is not None and old_entry[0] is not client:

328
cli.py
View File

@ -1651,10 +1651,18 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
else:
base_ref, base_label = "HEAD", "HEAD (local — worktree_sync disabled)"
# Create the worktree
# Create the worktree. checkout.workers parallelizes the file
# materialization (~6k files on this repo): 0.6s serial → ~0.2s with 8
# workers. Harmless on git builds without parallel-checkout support —
# unknown -c keys are ignored for checkout, and the fallback retry
# below drops the flags entirely.
_wt_add_cfg = [
"-c", "checkout.workers=8",
"-c", "checkout.thresholdForParallelism=100",
]
try:
result = subprocess.run(
["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
["git", *_wt_add_cfg, "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, cwd=repo_root,
)
if result.returncode != 0:
@ -4770,6 +4778,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._prompt_stash = _PromptStash()
self.preloaded_skills: list[str] = []
self._startup_skills_line_shown = False
# Background --skills preload (started by cmd_chat; joined by
# finalize_preloaded_skills before any agent is built).
self._preload_skills_thread: Optional[threading.Thread] = None
self._preload_skills_result: Optional[tuple] = None
self._preload_skills_error: Optional[BaseException] = None
self._preload_skills_requested: list = []
self._preload_skills_finalized = False
self._active_session_lease = None
# Voice mode state (also reinitialized inside run() for interactive TUI).
@ -7343,6 +7358,53 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# logged at DEBUG by the advisory module.
pass
def finalize_preloaded_skills(self) -> None:
"""Join the background --skills preload and fold it into the prompt.
Idempotent; no-op when no preload was requested. Called from
``_init_agent`` (before the agent snapshots ``self.system_prompt``)
and safe to call from any other consumer of the system prompt.
Raises ``ValueError`` when EVERY requested skill was unknown
the same contract the old synchronous path enforced in cmd_chat.
"""
if getattr(self, "_preload_skills_finalized", False):
return
thread = getattr(self, "_preload_skills_thread", None)
if thread is None:
self._preload_skills_finalized = True
return
thread.join(timeout=120)
self._preload_skills_finalized = True
err = getattr(self, "_preload_skills_error", None)
if err is not None:
raise err
result = getattr(self, "_preload_skills_result", None)
if not result:
return
skills_prompt, loaded_skills, missing_skills = result
if missing_skills:
missing_display = ", ".join(missing_skills)
# If at least one skill loaded, degrade gracefully: skip the
# unknown ones and continue. A typo'd skill name should not crash
# the worker (which auto-blocks the Kanban task after retries).
# Only when EVERY requested skill is missing do we hard-fail, so a
# fully-misconfigured worker fails loudly instead of running blind.
if loaded_skills:
logger.warning(
"Unknown skill(s) requested, skipping: %s. "
"Continuing with: %s. "
"List available skills with `hermes skills list`.",
missing_display,
", ".join(loaded_skills),
)
else:
raise ValueError(f"Unknown skill(s): {missing_display}")
if skills_prompt:
self.system_prompt = "\n\n".join(
part for part in (self.system_prompt, skills_prompt) if part
).strip()
self.preloaded_skills = loaded_skills
def show_banner(self):
"""Display the welcome banner in Claude Code style."""
self.console.clear()
@ -7359,28 +7421,115 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._console_print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
# Warm-launch fast path: replay last launch's tool panel when the
# snapshot fingerprint (config.yaml + .env + checkout rev +
# toolsets) is unchanged, skipping the ~0.5-0.9s cold
# get_tool_definitions walk. The agent's REAL tool list is still
# computed fresh at first message; a background refresh below
# re-verifies the snapshot so any drift self-heals next launch.
from hermes_cli.banner import (
compute_toolset_availability,
load_banner_snapshot,
save_banner_snapshot,
)
snapshot = None
try:
snapshot = load_banner_snapshot(self.enabled_toolsets)
except Exception:
snapshot = None
# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
# Build and display the banner
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)
if snapshot is not None:
self._defer_tool_warnings = True
toolset_map = snapshot["toolset_map"]
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=snapshot["tools"],
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
get_toolset_for_tool=lambda name: toolset_map.get(name),
context_length=ctx_len,
provider=self.provider,
availability=snapshot["availability"],
skills_by_category=snapshot.get("skills_by_category"),
)
def _refresh_banner_snapshot() -> None:
try:
from model_tools import get_toolset_for_tool
tools = get_tool_definitions(
enabled_toolsets=self.enabled_toolsets, quiet_mode=True
)
availability = compute_toolset_availability(self.enabled_toolsets)
tmap = {
t["function"]["name"]: get_toolset_for_tool(t["function"]["name"])
for t in tools
}
for item in availability.get("unavailable_toolsets", []):
for name in item.get("tools", []):
tmap.setdefault(
name, item.get("id", item.get("name", ""))
)
save_banner_snapshot(
tools, self.enabled_toolsets, availability, tmap
)
except Exception:
logger.debug("banner snapshot refresh failed", exc_info=True)
threading.Thread(
target=_refresh_banner_snapshot,
name="banner-snapshot-refresh",
daemon=True,
).start()
else:
# Cold path: compute everything live, then persist the snapshot
# so the next launch replays it.
from model_tools import get_toolset_for_tool
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
availability = compute_toolset_availability(self.enabled_toolsets)
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
availability=availability,
)
try:
tmap = {
t["function"]["name"]: get_toolset_for_tool(t["function"]["name"])
for t in tools
}
for item in availability.get("unavailable_toolsets", []):
for name in item.get("tools", []):
tmap.setdefault(name, item.get("id", item.get("name", "")))
save_banner_snapshot(tools, self.enabled_toolsets, availability, tmap)
except Exception:
logger.debug("banner snapshot save failed", exc_info=True)
# Tool discovery is intentionally deferred on the Termux bare prompt
# path; availability warnings are shown once tools are initialized.
# On the snapshot fast path (warm launch), the check walks every
# check_fn (~180ms) — run it in the background refresh thread instead
# and let its output land above the prompt (patch_stdout-safe).
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._show_tool_availability_warnings()
if getattr(self, "_defer_tool_warnings", False):
threading.Thread(
target=self._show_tool_availability_warnings,
name="tool-availability-warnings",
daemon=True,
).start()
else:
self._show_tool_availability_warnings()
# Warn about low context lengths (common with local servers). Keep
# this tied to the runtime guard so guidance cannot drift again.
@ -15294,8 +15443,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
maybe_pull_org_skills()
except Exception:
pass
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
_skills_for_line = self.preloaded_skills or list(
getattr(self, "_preload_skills_requested", []) or []
)
if _skills_for_line and not self._startup_skills_line_shown:
# When the background --skills preload hasn't been folded in yet
# (it joins at agent init), show the REQUESTED names — identical
# to the loaded set except for typo'd names, which warn later.
skills_label = ", ".join(_skills_for_line)
self._console_print(
f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
)
@ -18259,25 +18414,69 @@ def main(
use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
wt_info = None
if use_worktree:
# Prune stale worktrees from crashed/killed sessions
_repo = _git_repo_root()
if _repo:
_prune_stale_worktrees(_repo)
# Branch the worktree from the freshly-fetched remote tip by
# default so it starts current with the project. Opt out with
# worktree_sync: false to branch from local HEAD instead.
# Overlap tool discovery with the network/subprocess-bound
# worktree setup (base fetch + parallel `git worktree add`
# release the GIL for most of their wall time). show_banner()
# then hits the warm cache instead of paying ~0.4s serially.
# Only done on the -w path: on plain `hermes` there is no I/O
# wait to hide and the extra thread just contends for CPU.
def _prewarm_tools() -> None:
try:
import model_tools as _mt
_mt.get_tool_definitions(quiet_mode=True)
except Exception:
logger.debug("tool prewarm failed", exc_info=True)
threading.Thread(
target=_prewarm_tools, name="tool-prewarm", daemon=True
).start()
# Worktree creation itself (~0.2-0.6s of git subprocess wall
# time) runs concurrently with the rest of startup; join right
# after HermesCLI construction, before anything consumes
# TERMINAL_CWD / wt_info. Failure semantics preserved: setup
# failure still aborts the session (checked at join).
_sync_base = CLI_CONFIG.get("worktree_sync", True)
wt_info = _setup_worktree(sync_base=_sync_base)
if wt_info:
_active_worktree = wt_info
os.environ["TERMINAL_CWD"] = wt_info["path"]
atexit.register(_cleanup_worktree, wt_info)
else:
# Worktree was explicitly requested but setup failed —
# don't silently run without isolation.
return
_wt_result: dict = {}
def _create_worktree() -> None:
try:
_wt_result["info"] = _setup_worktree(sync_base=_sync_base)
except Exception:
logger.debug("worktree setup failed", exc_info=True)
_wt_result["info"] = None
_wt_thread = threading.Thread(
target=_create_worktree, name="worktree-setup", daemon=True
)
_wt_thread.start()
def _join_worktree() -> Optional[Dict[str, str]]:
_wt_thread.join(timeout=120)
info = _wt_result.get("info")
if info:
global _active_worktree
_active_worktree = info
os.environ["TERMINAL_CWD"] = info["path"]
atexit.register(_cleanup_worktree, info)
# Prune stale worktrees from crashed/killed sessions in
# the background — pure GC, nothing downstream depends
# on it. Ordered AFTER _setup_worktree so the two never
# race on git's worktrees metadata; the new tree itself
# is immune to reaping (<24h age gate + live pid lock).
_repo = _git_repo_root()
if _repo:
threading.Thread(
target=_prune_stale_worktrees,
args=(_repo,),
name="worktree-prune",
daemon=True,
).start()
return info
else:
_join_worktree = None
else:
wt_info = None
_join_worktree = None
wt_info = None
# Handle query shorthand
query = query or q
@ -18333,32 +18532,37 @@ def main(
)
if parsed_skills:
skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(
parsed_skills,
task_id=cli.session_id,
)
if missing_skills:
missing_display = ", ".join(missing_skills)
# If at least one skill loaded, degrade gracefully: skip the
# unknown ones and continue. A typo'd skill name should not crash
# the worker (which auto-blocks the Kanban task after retries).
# Only when EVERY requested skill is missing do we hard-fail, so a
# fully-misconfigured worker fails loudly instead of running blind.
if loaded_skills:
logger.warning(
"Unknown skill(s) requested, skipping: %s. "
"Continuing with: %s. "
"List available skills with `hermes skills list`.",
missing_display,
", ".join(loaded_skills),
# Load the skill payloads in the background: skill_view walks the
# full skills tree per skill (~0.5s for a large library) and the
# result is only consumed at agent init (first message / first
# agent-touching command), not by the banner. cmd_chat joins the
# thread via cli.finalize_preloaded_skills() before any consumer
# reads cli.system_prompt — HermesCLI._create_agent calls it too,
# so no agent can be built with the skills missing.
def _load_preloaded_skills() -> None:
try:
cli._preload_skills_result = build_preloaded_skills_prompt(
parsed_skills,
task_id=cli.session_id,
)
else:
raise ValueError(f"Unknown skill(s): {missing_display}")
if skills_prompt:
cli.system_prompt = "\n\n".join(
part for part in (cli.system_prompt, skills_prompt) if part
).strip()
cli.preloaded_skills = loaded_skills
except Exception as exc: # surfaced by finalize below
cli._preload_skills_error = exc
cli._preload_skills_requested = parsed_skills
cli._preload_skills_thread = threading.Thread(
target=_load_preloaded_skills, name="skills-preload", daemon=True
)
cli._preload_skills_thread.start()
# Join the background worktree creation (started above) before anything
# consumes TERMINAL_CWD / wt_info — the HermesCLI construction it
# overlapped with is done. Setup failure keeps the old abort semantics.
if _join_worktree is not None:
wt_info = _join_worktree()
if not wt_info:
# Worktree was explicitly requested but setup failed —
# don't silently run without isolation.
return
# Inject worktree context into agent's system prompt
if wt_info:

View File

@ -33,6 +33,46 @@ import threading
import time
import uuid
import webbrowser
# httpx is imported lazily: it costs ~30ms at import time and hermes_cli.auth
# is on the interactive-CLI startup path via credential_pool → auxiliary_client
# → cli_commands_mixin, where no HTTP request is ever made before first use.
# The proxy resolves to the real module on first attribute access; every
# consumer in this file uses `httpx.<attr>` so the swap is transparent.
# Annotations like ``httpx.Client`` stay valid: `from __future__ import
# annotations` (above) keeps them unevaluated at runtime, and the
# TYPE_CHECKING import gives static checkers the real module.
import importlib as _importlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import httpx
else:
class _LazyHttpx:
__slots__ = ("_mod",)
def __init__(self) -> None:
object.__setattr__(self, "_mod", None)
def _resolve(self):
mod = object.__getattribute__(self, "_mod")
if mod is None:
mod = _importlib.import_module("httpx")
object.__setattr__(self, "_mod", mod)
return mod
def __getattr__(self, name):
return getattr(self._resolve(), name)
# Forward set/del to the real module so monkeypatch.setattr
# ("hermes_cli.auth.httpx.Client", ...) keeps working in tests.
def __setattr__(self, name, value):
setattr(self._resolve(), name, value)
def __delattr__(self, name):
delattr(self._resolve(), name)
httpx = _LazyHttpx()
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
@ -41,8 +81,6 @@ from pathlib import Path
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
from hermes_cli.config import (
get_hermes_home,
get_config_path,

View File

@ -12,7 +12,7 @@ import time
from pathlib import Path
from urllib.parse import urlparse
from hermes_constants import get_hermes_home
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional
# rich and prompt_toolkit are imported lazily (inside the functions that use
# them) rather than at module level. Importing this module is on the TUI
@ -96,13 +96,23 @@ HERMES_CADUCEUS = """[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀
# Skills scanning
# =========================================================================
_available_skills_cache: Optional[tuple] = None # (result,) once computed
def get_available_skills() -> Dict[str, List[str]]:
"""Return skills grouped by category, filtered by platform and disabled state.
Delegates to ``_find_all_skills()`` from ``tools/skills_tool`` which already
handles platform gating (``platforms:`` frontmatter) and respects the
user's ``skills.disabled`` config list.
Cached per-process: this feeds only the startup banner, whose snapshot
is taken once anyway, and the underlying skills-tree walk costs ~100ms.
``prefetch_banner_data()`` uses the cache to pay that walk off-thread.
"""
global _available_skills_cache
if _available_skills_cache is not None:
return _available_skills_cache[0]
try:
from tools.skills_tool import _find_all_skills
all_skills = _find_all_skills() # already filtered
@ -113,6 +123,7 @@ def get_available_skills() -> Dict[str, List[str]]:
for skill in all_skills:
category = skill.get("category") or "general"
skills_by_category.setdefault(category, []).append(skill["name"])
_available_skills_cache = (skills_by_category,)
return skills_by_category
@ -379,6 +390,9 @@ def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
return value or None
_git_banner_state_cache: Optional[tuple] = None # (state_or_None,) once computed
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
"""Return upstream/local git hashes for the startup banner.
@ -391,7 +405,23 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
definition pinned to one commit, so "ahead" is always zero and the
banner correctly shows ``· upstream <sha>`` with no carried-commits
annotation.
Cached per-process (default ``repo_dir`` only): the state costs 2-3 git
subprocesses (~100ms) and the checkout revision cannot change under a
running CLI in a way the banner needs to observe live. The cache also
lets ``prefetch_banner_data()`` pay this cost off-thread before the
banner renders.
"""
global _git_banner_state_cache
if repo_dir is None and _git_banner_state_cache is not None:
return _git_banner_state_cache[0]
state = _compute_git_banner_state(repo_dir)
if repo_dir is None:
_git_banner_state_cache = (state,)
return state
def _compute_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
repo_dir = repo_dir or _resolve_repo_dir()
if repo_dir is None:
# No git checkout — try the baked build SHA (Docker image path).
@ -521,12 +551,97 @@ def prefetch_update_check():
t.start()
_banner_data_prefetch_started = False
def prefetch_banner_data():
"""Warm the banner's subprocess/I/O-heavy inputs in a daemon thread.
``build_welcome_banner`` needs git state (2-4 ``git rev-parse``/
``describe`` subprocesses, ~130ms) and the skills index (a skills-tree
rglob, ~110ms). Both are cached per-process by their own modules, so
warming them here while the main thread pays the CPU-bound ``cli`` /
prompt_toolkit imports overlaps subprocess waits and file I/O (which
release the GIL) with import work. Idempotent; failures are irrelevant
because the banner recomputes anything missing.
"""
global _banner_data_prefetch_started
if _banner_data_prefetch_started:
return
_banner_data_prefetch_started = True
def _run() -> None:
try:
get_git_banner_state()
except Exception:
pass
try:
get_latest_release_tag()
except Exception:
pass
try:
get_available_skills()
except Exception:
pass
threading.Thread(target=_run, name="banner-data-prefetch", daemon=True).start()
def get_update_result(timeout: float = 0.5) -> Optional[int]:
"""Get result of prefetched check. Returns None if not ready."""
_update_check_done.wait(timeout=timeout)
return _update_result
def _format_update_notice(behind: int) -> str:
"""Render the update warning line for a non-zero ``behind`` result."""
from hermes_cli.config import get_managed_update_command, recommended_update_command
if behind > 0:
commits_word = "commit" if behind == 1 else "commits"
return (
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
)
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
# exists but not by how much, and we don't know how the user
# installed it (nix run, profile, system flake, home-manager).
managed_cmd = get_managed_update_command()
line = "[bold yellow]⚠ update available[/]"
if managed_cmd:
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
return line
_deferred_update_notice_started = False
def _defer_update_notice(console: "Console", max_wait: float = 30.0) -> None:
"""Print the update warning once the prefetched check completes.
Used when the banner rendered before the update prefetch finished so
startup never blocks on git/network. Prints at most once per process.
"""
global _deferred_update_notice_started
if _deferred_update_notice_started:
return
_deferred_update_notice_started = True
def _wait_and_print() -> None:
try:
if not _update_check_done.wait(timeout=max_wait):
return
behind = _update_result
if behind is None or behind == 0:
return
console.print(_format_update_notice(behind))
except Exception:
pass # never break the session over an update notice
threading.Thread(
target=_wait_and_print, name="update-notice", daemon=True
).start()
# =========================================================================
# Welcome banner
# =========================================================================
@ -559,37 +674,124 @@ def _display_toolset_name(toolset_name: str) -> str:
)
def build_welcome_banner(console: "Console", model: str, cwd: str,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
session_id: str = None,
get_toolset_for_tool=None,
context_length: int = None,
provider: str = None):
"""Build and print a welcome banner with caduceus on left and info on right.
# =========================================================================
# Banner snapshot — warm-launch fast path
# =========================================================================
# The banner's tool panel needs the full tool registry (get_tool_definitions:
# tools/*.py discovery + every check_fn), which costs ~0.5-0.9s cold and is
# the single largest chunk of CLI time-to-banner. The tool list shown in the
# banner is a pure function of (config.yaml, .env, code checkout, enabled
# toolsets), so we snapshot the rendered inputs to disk after each launch
# and replay them on the next one when the fingerprint matches. The agent's
# REAL tool list is still computed fresh at first message (agent init) —
# the snapshot only feeds the cosmetic startup panel, and a background
# refresh re-verifies it right after the banner renders (see
# cli.show_banner), so a stale panel self-heals within one launch.
Args:
console: Rich Console instance.
model: Current model name.
cwd: Current working directory.
tools: List of tool definitions.
enabled_toolsets: List of enabled toolset names.
session_id: Session identifier.
get_toolset_for_tool: Callable to map tool name -> toolset name.
context_length: Model's context window size in tokens.
provider: Active provider id. When ``"moa"``, ``model`` is a MoA
preset name and the banner renders the aggregator instead of a
bare model slug.
_BANNER_SNAPSHOT_VERSION = 1
def _banner_snapshot_path() -> Path:
return get_hermes_home() / "cache" / "banner_snapshot.json"
def banner_snapshot_fingerprint() -> Optional[str]:
"""Fingerprint the inputs the banner tool panel depends on."""
import hashlib
parts = [f"v{_BANNER_SNAPSHOT_VERSION}"]
try:
from hermes_cli.config import get_config_path
for p in (get_config_path(), get_hermes_home() / ".env"):
try:
st = p.stat()
parts.append(f"{p.name}:{st.st_mtime_ns}:{st.st_size}")
except OSError:
parts.append(f"{p.name}:absent")
except Exception:
return None
# Code checkout: version + git HEAD when available (post-update change).
parts.append(str(VERSION))
state = get_git_banner_state()
if state:
parts.append(str(state.get("local", "")))
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
def load_banner_snapshot(enabled_toolsets: List[str] = None) -> Optional[Dict[str, Any]]:
"""Return the stored banner snapshot when its fingerprint is current."""
try:
blob = json.loads(_banner_snapshot_path().read_text(encoding="utf-8"))
except Exception:
return None
if not isinstance(blob, dict):
return None
fp = banner_snapshot_fingerprint()
if not fp or blob.get("fingerprint") != fp:
return None
if blob.get("enabled_toolsets") != sorted(enabled_toolsets or []):
return None
tools = blob.get("tools")
toolset_map = blob.get("toolset_map")
availability = blob.get("availability")
if not isinstance(tools, list) or not isinstance(toolset_map, dict) \
or not isinstance(availability, dict):
return None
if not isinstance(blob.get("skills_by_category"), dict):
return None
return blob
def save_banner_snapshot(
tools: List[dict],
enabled_toolsets: List[str],
availability: Dict[str, Any],
toolset_map: Dict[str, str],
) -> None:
"""Persist the banner tool panel inputs for next launch (best-effort)."""
fp = banner_snapshot_fingerprint()
if not fp:
return
payload = {
"fingerprint": fp,
"enabled_toolsets": sorted(enabled_toolsets or []),
"tools": [
{"function": {"name": t["function"]["name"]}}
for t in tools
if isinstance(t, dict) and t.get("function", {}).get("name")
],
"toolset_map": toolset_map,
"availability": {
"unavailable_toolsets": availability.get("unavailable_toolsets", []),
"lazy_tools": list(availability.get("lazy_tools", [])),
"disabled_tools": list(availability.get("disabled_tools", [])),
},
"skills_by_category": get_available_skills(),
}
path = _banner_snapshot_path()
try:
import os as _os
import tempfile as _tempfile
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = _tempfile.mkstemp(dir=str(path.parent), prefix=".banner_snap.")
with _os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh)
_os.replace(tmp, path)
except Exception:
pass
def compute_toolset_availability(enabled_toolsets: List[str] = None) -> Dict[str, Any]:
"""Compute the banner's toolset-availability payload.
Returns ``{"unavailable_toolsets": [...], "lazy_tools": [...],
"disabled_tools": [...]}`` the exact inputs ``build_welcome_banner``
needs to annotate disabled/lazy tools. Split out so the result can be
snapshotted to disk and replayed on the next launch without importing
``model_tools`` (see ``load_banner_snapshot``).
"""
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
from rich.panel import Panel
from rich.table import Table
if get_toolset_for_tool is None:
from model_tools import get_toolset_for_tool
tools = tools or []
enabled_toolsets = enabled_toolsets or []
_, unavailable_toolsets = check_tool_availability(quiet=True)
# The availability check walks the GLOBAL toolset registry, so it includes
# toolsets that aren't part of this agent's platform set at all (e.g.
@ -616,6 +818,55 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
lazy_tools.update(tools_in_ts)
else:
disabled_tools.update(tools_in_ts)
return {
"unavailable_toolsets": unavailable_toolsets,
"lazy_tools": sorted(lazy_tools),
"disabled_tools": sorted(disabled_tools),
}
def build_welcome_banner(console: "Console", model: str, cwd: str,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
session_id: str = None,
get_toolset_for_tool=None,
context_length: int = None,
provider: str = None,
availability: Dict[str, Any] = None,
skills_by_category: Dict[str, List[str]] = None):
"""Build and print a welcome banner with caduceus on left and info on right.
Args:
console: Rich Console instance.
model: Current model name.
cwd: Current working directory.
tools: List of tool definitions.
enabled_toolsets: List of enabled toolset names.
session_id: Session identifier.
get_toolset_for_tool: Callable to map tool name -> toolset name.
context_length: Model's context window size in tokens.
provider: Active provider id. When ``"moa"``, ``model`` is a MoA
preset name and the banner renders the aggregator instead of a
bare model slug.
availability: Optional precomputed result of
``compute_toolset_availability`` (e.g. replayed from the banner
snapshot). When provided together with ``get_toolset_for_tool``,
this function performs no ``model_tools`` import at all.
"""
from rich.panel import Panel
from rich.table import Table
if get_toolset_for_tool is None:
from model_tools import get_toolset_for_tool
tools = tools or []
enabled_toolsets = enabled_toolsets or []
if availability is None:
availability = compute_toolset_availability(enabled_toolsets)
unavailable_toolsets = availability.get("unavailable_toolsets", [])
lazy_tools = set(availability.get("lazy_tools", []))
disabled_tools = set(availability.get("disabled_tools", []))
_enabled_ts = {str(t) for t in enabled_toolsets}
layout_table = Table.grid(padding=(0, 2))
layout_table.add_column("left", justify="center")
@ -742,12 +993,30 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
if remaining_toolsets > 0:
right_lines.append(f"[dim {dim}](and {remaining_toolsets} more toolsets...)[/]")
# MCP Servers section (only if configured)
# MCP Servers section (only if configured). Probe cheaply first: the
# full get_mcp_status() path resolves portable plugin MCP servers,
# which JOINS the in-flight background plugin discovery (~100ms on the
# startup path). When neither config.yaml nor the persisted plugin
# key cache mentions any MCP server, skip the section outright.
mcp_status = []
try:
from tools.mcp_tool import get_mcp_status
mcp_status = get_mcp_status()
from hermes_cli.config import load_config as _load_cfg
_has_native_mcp = bool((_load_cfg() or {}).get("mcp_servers"))
except Exception:
mcp_status = []
_has_native_mcp = True # can't tell — take the full path
_has_portable_mcp = False
if not _has_native_mcp:
try:
from hermes_cli.plugins import get_portable_mcp_server_names_nowait
_has_portable_mcp = bool(get_portable_mcp_server_names_nowait())
except Exception:
_has_portable_mcp = True # can't tell — take the full path
if _has_native_mcp or _has_portable_mcp:
try:
from tools.mcp_tool import get_mcp_status
mcp_status = get_mcp_status()
except Exception:
mcp_status = []
if mcp_status:
right_lines.append("")
@ -788,7 +1057,8 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
# the on-disk catalog here is misleading. Reflect the real state instead.
_skills_enabled = (not _enabled_ts) or ("skills" in _enabled_ts)
if _skills_enabled:
skills_by_category = get_available_skills()
if skills_by_category is None:
skills_by_category = get_available_skills()
total_skills = sum(len(s) for s in skills_by_category.values())
else:
skills_by_category = {}
@ -856,26 +1126,19 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]")
# Update check — use prefetched result if available
# Update check — use prefetched result if available. NEVER block the
# banner on it: the prefetch does git/network work that rarely finishes
# before the banner renders, so a blocking wait here just adds its full
# timeout to every startup (500ms of the banner path pre-fix). If the
# result isn't ready yet, defer the warning line: a daemon thread waits
# for the prefetch and prints the same notice above the prompt when it
# lands (prompt_toolkit's patch_stdout renders late prints safely).
try:
behind = get_update_result(timeout=0.5)
if behind is not None and behind != 0:
from hermes_cli.config import get_managed_update_command, recommended_update_command
if behind > 0:
commits_word = "commit" if behind == 1 else "commits"
right_lines.append(
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
)
else:
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
# exists but not by how much, and we don't know how the user
# installed it (nix run, profile, system flake, home-manager).
managed_cmd = get_managed_update_command()
line = "[bold yellow]⚠ update available[/]"
if managed_cmd:
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
right_lines.append(line)
behind = get_update_result(timeout=0.05)
if behind is None and not _update_check_done.is_set():
_defer_update_notice(console)
elif behind is not None and behind != 0:
right_lines.append(_format_update_notice(behind))
except Exception:
pass # Never break the banner over an update check

View File

@ -342,6 +342,11 @@ class CLIAgentSetupMixin:
if self.agent is not None:
return True
# Join the background preloaded-skills load (cli.py cmd_chat starts
# it when --skills/-s is passed) BEFORE the agent snapshots
# self.system_prompt below. No-op when nothing was requested.
self.finalize_preloaded_skills()
_prepare_deferred_agent_startup()
self._install_tool_callbacks()
self._ensure_tirith_security()

View File

@ -2684,17 +2684,31 @@ def cmd_chat(args):
# competes for CPU on single-core devices, so keep it opt-in there.
if _termux_should_prefetch_update_check():
try:
from hermes_cli.banner import prefetch_update_check
from hermes_cli.banner import prefetch_banner_data, prefetch_update_check
prefetch_update_check()
# Warm git banner state + skills index off-thread too — their
# subprocess/file-I/O waits overlap the CPU-bound cli import.
prefetch_banner_data()
except Exception:
pass
# Sync bundled skills on every CLI launch (fast -- skips unchanged skills)
try:
_sync_bundled_skills_for_startup()
except Exception:
pass
# Sync bundled skills on every CLI launch. Runs in a background daemon
# thread: the sync is idempotent, hash-gated (unchanged skills are
# skipped), and nothing on the banner path depends on it, yet the scan
# alone costs ~120-170ms of rglob/hashing on the startup path. Skill
# loading happens at agent init (first message), by which point the
# sync has long finished; a same-instant race would only matter in the
# rare launch right after `hermes update` changed a bundled skill.
def _skills_sync_bg() -> None:
try:
_sync_bundled_skills_for_startup()
except Exception:
pass
threading.Thread(
target=_skills_sync_bg, name="bundled-skills-sync", daemon=True
).start()
# --yolo: bypass all dangerous command approvals.
# Also set in main() before _prepare_agent_startup() — that is the
@ -10835,9 +10849,16 @@ def _prepare_agent_startup(args) -> None:
_accept_hooks = bool(getattr(args, "accept_hooks", False))
try:
from hermes_cli.plugins import discover_plugins
from hermes_cli.plugins import start_background_plugin_discovery
discover_plugins()
# Discovery runs in a daemon thread so its ~150ms of manifest
# scanning + plugin imports overlaps the rest of startup (cli /
# prompt_toolkit imports, worktree git calls). Correctness is
# unchanged: every synchronous reader goes through
# discover_plugins(), which joins this thread first — including
# the discover_plugins() call model_tools makes at import time,
# which happens before any tool list is built.
start_background_plugin_discovery()
except Exception:
logger.warning(
"plugin discovery failed at CLI startup",
@ -10921,6 +10942,79 @@ def _set_chat_arg_defaults(args) -> None:
setattr(args, attr, default)
def _try_fast_chat_launch() -> bool:
"""Fast path for unambiguous interactive chat launches (all hosts).
``hermes`` / ``hermes -w -s foo --yolo`` / ``hermes chat`` don't need the
full argparse tree: building all ~40 subcommand parsers costs ~140ms of
pure-Python argparse setup plus their module imports, none of which the
chat path uses. Parse the lightweight top-level/chat parser instead and
dispatch straight to ``cmd_chat``.
Bails out (returns False) whenever the invocation is not certainly a
chat launch a subcommand positional, ``--help``, unknown flags so
every other path still goes through the full parser unchanged. Mirrors
``_try_termux_fast_cli_launch`` minus the Termux-specific deferred
startup; kept separate so phone-tuned behavior doesn't leak to desktops.
"""
if os.environ.get("HERMES_DISABLE_FAST_CHAT_LAUNCH") == "1":
return False
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
return False
# Container-aware routing must win: when NixOS container mode is
# active, EVERY invocation is forwarded into the managed container.
try:
from hermes_cli.config import get_container_exec_info
if get_container_exec_info():
return False
except Exception:
return False
# TUI launches have their own startup path (bounded MCP joins etc.) —
# keep them on full dispatch outside Termux.
if _wants_tui_early(argv):
return False
if _first_positional_argv() not in {None, "chat"}:
return False
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
try:
args, unknown = parser.parse_known_args(_coalesce_session_name_args(argv))
except SystemExit:
return False
if unknown:
# Flags the light parser doesn't know — could belong to a plugin
# subcommand or a newer full-parser flag. Fall back to full dispatch.
return False
if getattr(args, "version", False):
return False
if getattr(args, "command", None) not in {None, "chat"}:
return False
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
_prepare_agent_startup(args)
if getattr(args, "oneshot", None):
_run_and_exit_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
)
if (args.resume or args.continue_last) and args.command is None:
args.command = "chat"
_set_chat_arg_defaults(args)
cmd_chat(args)
return True
def _try_termux_fast_cli_launch() -> bool:
"""Run obvious Termux non-TUI chat/oneshot/version paths on a light parser."""
if not _is_termux_startup_environment():
@ -11278,6 +11372,8 @@ def main():
return
if _try_termux_fast_cli_launch():
return
if _try_fast_chat_launch():
return
from hermes_cli._parser import build_top_level_parser

View File

@ -2286,10 +2286,144 @@ def discover_plugins(force: bool = False) -> None:
Default behavior is idempotent. Pass ``force=True`` to rescan plugin
manifests and reload state in the current process.
If a background discovery started via
:func:`start_background_plugin_discovery` is still running, this waits
for it instead of racing a second scan.
"""
_join_background_discovery()
get_plugin_manager().discover_and_load(force=force)
_background_discovery_thread: Optional[threading.Thread] = None
_background_discovery_lock = threading.Lock()
def start_background_plugin_discovery() -> None:
"""Run plugin discovery in a daemon thread (startup-latency overlap).
Discovery costs ~150ms of manifest scanning + module imports on the CLI
startup path. Interactive chat doesn't need plugins until the first
agent turn, so callers on that path can start discovery here and let it
overlap the CPU/subprocess-heavy rest of startup. Every synchronous
consumer goes through :func:`discover_plugins`, which joins this thread
first so no caller can observe a half-loaded registry. Idempotent;
no-op when discovery already ran or is already in flight.
"""
global _background_discovery_thread
manager = get_plugin_manager()
if manager._discovered:
return
with _background_discovery_lock:
if _background_discovery_thread is not None and _background_discovery_thread.is_alive():
return
def _run() -> None:
try:
manager.discover_and_load()
_persist_plugin_toolset_keys()
except Exception:
logger.warning("background plugin discovery failed", exc_info=True)
_background_discovery_thread = threading.Thread(
target=_run, name="plugin-discovery", daemon=True
)
_background_discovery_thread.start()
def _join_background_discovery(timeout: float = 30.0) -> None:
"""Wait for an in-flight background discovery (no-op from its own thread)."""
t = _background_discovery_thread
if t is None or not t.is_alive() or t is threading.current_thread():
return
t.join(timeout=timeout)
def _plugin_toolset_keys_cache_path():
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "plugin_toolset_keys.json"
def _persist_plugin_toolset_keys() -> None:
"""Persist discovered plugin toolset keys + portable MCP names (best-effort)."""
try:
import json as _json
import os as _os
import tempfile as _tempfile
keys = sorted({ts_key for ts_key, _, _ in get_plugin_toolsets()})
try:
portable = sorted(get_plugin_manager().get_portable_mcp_servers())
except Exception:
portable = []
path = _plugin_toolset_keys_cache_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = _tempfile.mkstemp(dir=str(path.parent), prefix=".pt_keys.")
with _os.fdopen(fd, "w", encoding="utf-8") as fh:
_json.dump({"toolset_keys": keys, "portable_mcp": portable}, fh)
_os.replace(tmp, path)
except Exception:
logger.debug("plugin toolset key persist failed", exc_info=True)
def _read_plugin_keys_cache() -> Optional[dict]:
try:
import json as _json
blob = _json.loads(
_plugin_toolset_keys_cache_path().read_text(encoding="utf-8")
)
if isinstance(blob, dict):
return blob
except Exception:
pass
return None
def get_plugin_toolset_keys_nowait() -> "set[str]":
"""Plugin toolset keys without blocking on in-flight discovery.
When discovery already completed in this process, reads the live
registry. While a background discovery is still running, falls back to
the key set persisted by the previous run callers on the startup path
(platform toolset resolution) only use these keys to EXCLUDE plugin
toolsets from composite expansion, so a stale set from the last launch
is harmless and self-heals as soon as discovery lands. When neither is
available, blocks via discover_plugins() (correctness first).
"""
manager = get_plugin_manager()
t = _background_discovery_thread
if manager._discovered and (t is None or not t.is_alive()):
return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
if t is not None and t.is_alive():
blob = _read_plugin_keys_cache()
if blob is not None:
keys = blob.get("toolset_keys")
if isinstance(keys, list) and all(isinstance(k, str) for k in keys):
return set(keys)
discover_plugins()
return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
def get_portable_mcp_server_names_nowait() -> "set[str]":
"""Portable MCP server names without blocking on in-flight discovery.
Same contract as :func:`get_plugin_toolset_keys_nowait`: live registry
when discovery finished, last launch's persisted set while a background
discovery is running, blocking discovery otherwise.
"""
manager = get_plugin_manager()
t = _background_discovery_thread
if manager._discovered and (t is None or not t.is_alive()):
return set(manager.get_portable_mcp_servers())
if t is not None and t.is_alive():
blob = _read_plugin_keys_cache()
if blob is not None:
names = blob.get("portable_mcp")
if isinstance(names, list) and all(isinstance(n, str) for n in names):
return set(names)
discover_plugins()
return set(manager.get_portable_mcp_servers())
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
"""Invoke a lifecycle hook on loaded plugins.

View File

@ -271,9 +271,12 @@ def _get_effective_configurable_toolsets():
def _get_plugin_toolset_keys() -> set:
"""Return the set of toolset keys provided by plugins."""
try:
from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
discover_plugins() # idempotent — ensures plugins are loaded
return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
from hermes_cli.plugins import get_plugin_toolset_keys_nowait
# Non-blocking on the CLI startup path: while background plugin
# discovery is still importing modules, this serves last launch's
# persisted key set (used only to exclude plugin toolsets from
# composite expansion) instead of joining the discovery thread.
return get_plugin_toolset_keys_nowait()
except Exception:
return set()
@ -2146,10 +2149,12 @@ def enabled_mcp_server_names(config: dict) -> Set[str]:
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
from hermes_cli.plugins import (
get_plugin_manager,
get_portable_mcp_server_names_nowait,
)
discover_plugins()
portable = set(get_plugin_manager().get_portable_mcp_servers())
portable = get_portable_mcp_server_names_nowait()
# Native config wins on a name collision (mirrors _load_mcp_config).
names |= portable - set(mcp_servers)
except Exception:

View File

@ -451,7 +451,39 @@ class SessionSchemaMixin:
Adding a column to SCHEMA_SQL is all that's needed; the
reconciliation loop picks it up automatically.
The parse result is memoized on disk keyed by a hash of the DDL:
executing SCHEMA_SQL (FTS5 virtual tables included) in the scratch
DB costs ~85ms on every startup, but the output is a pure function
of the DDL text, which only changes when the shipped code changes.
Reconciliation itself (diffing the LIVE database) still runs every
startup only the reference-side parse is cached. A corrupt or
stale cache degrades to recomputation.
"""
import hashlib as _hashlib
import json as _json
cache_path = None
schema_hash = _hashlib.sha256(schema_sql.encode("utf-8")).hexdigest()
try:
from hermes_constants import get_hermes_home
cache_path = get_hermes_home() / "cache" / "schema_columns.json"
blob = _json.loads(cache_path.read_text(encoding="utf-8"))
if (
isinstance(blob, dict)
and blob.get("schema_hash") == schema_hash
and isinstance(blob.get("tables"), dict)
):
tables = blob["tables"]
if all(
isinstance(cols, dict)
and all(isinstance(v, str) for v in cols.values())
for cols in tables.values()
):
return tables
except Exception:
pass # missing/corrupt cache → recompute below
ref = sqlite3.connect(":memory:")
try:
ref.executescript(schema_sql)
@ -478,10 +510,26 @@ class SessionSchemaMixin:
parts.append(f"DEFAULT {default}")
cols[col_name] = " ".join(parts)
table_columns[tbl] = cols
return table_columns
finally:
ref.close()
if cache_path is not None:
try:
import os as _os
import tempfile as _tempfile
cache_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = _tempfile.mkstemp(
dir=str(cache_path.parent), prefix=".schema_columns."
)
with _os.fdopen(fd, "w", encoding="utf-8") as fh:
_json.dump(
{"schema_hash": schema_hash, "tables": table_columns}, fh
)
_os.replace(tmp, cache_path)
except Exception:
pass # cache write is best-effort
return table_columns
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
"""Ensure live tables have every column declared in SCHEMA_SQL.

View File

@ -657,6 +657,24 @@ def _resolve_active_context_length() -> int:
"context gate (provider=%s): %s — using config values only",
provider, rt_exc,
)
# Fast path: a previously discovered on-disk cache entry is plenty
# for SIZING the tool-search gate — unlike compression budgeting, a
# slightly stale window can't corrupt anything (should_activate only
# picks a disclosure tier). The full resolver below deliberately
# bypasses the persistent cache for some providers (Nous portal,
# Codex OAuth) so IT can reconcile against the authoritative live
# /models endpoint — correct for compression sizing, but it costs a
# ~200ms network probe on EVERY CLI startup. When any prior session
# already learned the window, use it for the gate and let the full
# resolver (called later on the compression path) do reconciliation.
if config_ctx is None and base_url:
try:
from agent.model_metadata import get_cached_context_length
cached_ctx = get_cached_context_length(model_id, base_url)
if isinstance(cached_ctx, int) and cached_ctx > 0:
return cached_ctx
except Exception:
pass
return int(get_model_context_length(
model_id,
base_url=base_url,

View File

@ -68,6 +68,19 @@ class _DummyCLI:
return None
def _real_finalize(cli_obj):
"""Call the real HermesCLI.finalize_preloaded_skills on a dummy object."""
return _REAL_FINALIZE(cli_obj)
def _capture_real_finalize():
import cli as cli_mod
return cli_mod.HermesCLI.__dict__["finalize_preloaded_skills"]
_REAL_FINALIZE = _capture_real_finalize()
def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch):
import cli as cli_mod
@ -88,6 +101,10 @@ def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch):
cli_mod.main(skills="hermes-agent-dev,github-auth", list_tools=True)
cli_obj = created["cli"]
# The preload now runs in a background thread and is folded in at agent
# init via finalize_preloaded_skills() (startup-latency change). Drive
# the finalize explicitly — the same call _init_agent makes.
_real_finalize(cli_obj)
assert cli_obj.system_prompt == "base prompt\n\nskill prompt"
assert cli_obj.preloaded_skills == ["hermes-agent-dev", "github-auth"]
@ -95,16 +112,27 @@ def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch):
def test_main_raises_for_unknown_preloaded_skill(monkeypatch):
import cli as cli_mod
monkeypatch.setattr(cli_mod, "HermesCLI", lambda **kwargs: _DummyCLI(**kwargs))
created = {}
def fake_cli(**kwargs):
created["cli"] = _DummyCLI(**kwargs)
return created["cli"]
monkeypatch.setattr(cli_mod, "HermesCLI", fake_cli)
monkeypatch.setattr(
cli_mod,
"build_preloaded_skills_prompt",
lambda skills, task_id=None: ("", [], ["missing-skill"]),
)
with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"):
with pytest.raises(SystemExit):
cli_mod.main(skills="missing-skill", list_tools=True)
# The all-skills-unknown hard failure now surfaces when the preload is
# finalized (agent init), preserving the fail-loud contract.
with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"):
_real_finalize(created["cli"])
def test_show_banner_does_not_print_skills():
"""show_banner() no longer prints the activated skills line — it moved to run()."""

View File

@ -1,5 +1,6 @@
"""Tests for banner get_available_skills() — disabled and platform filtering."""
import pytest
from unittest.mock import patch
@ -10,6 +11,17 @@ _MOCK_SKILLS = [
]
@pytest.fixture(autouse=True)
def _reset_skills_cache():
"""get_available_skills is memoized per-process (startup perf) — reset
the cache around each test so patched _find_all_skills results are
actually observed."""
import hermes_cli.banner as banner
banner._available_skills_cache = None
yield
banner._available_skills_cache = None
def test_get_available_skills_delegates_to_find_all_skills():
"""get_available_skills should call _find_all_skills (which handles filtering)."""
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
@ -31,3 +43,20 @@ def test_get_available_skills_null_category_becomes_general():
assert "general" in result
assert result["general"] == ["orphan-skill"]
def test_get_available_skills_is_memoized():
"""Second call must not re-walk the skills tree (startup perf contract)."""
import hermes_cli.banner as banner
calls = []
def fake_find(**kwargs):
calls.append(1)
return list(_MOCK_SKILLS)
with patch("tools.skills_tool._find_all_skills", side_effect=fake_find):
first = banner.get_available_skills()
second = banner.get_available_skills()
assert first == second
assert len(calls) == 1

View File

@ -13,6 +13,26 @@ from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _materialize_mcp_sdk_symbols():
"""Materialize the lazily-imported MCP SDK before each tools test.
``tools/mcp_tool.py`` defers the ~260ms ``mcp`` SDK import until first
real use (CLI startup perf). Tests in this directory patch SDK symbols
(``ClientSession``, ``stdio_client``, ``_MCP_HTTP_AVAILABLE``, ...) on
the module and expect the pre-lazy eager-import world: symbols bound,
availability flags reflecting the installed SDK. Ensure that state up
front so ``mock.patch`` sees real originals and ``_ensure_mcp_sdk()``
can never clobber a patched flag mid-test (it no-ops once attempted).
"""
try:
from tools import mcp_tool
mcp_tool._ensure_mcp_sdk()
except Exception:
pass
yield
def register_all_web_providers():
"""Register all bundled web-search providers into the global registry.

View File

@ -0,0 +1,211 @@
"""Startup-latency regressions: probe-mode aux clients, lazy MCP SDK,
non-blocking banner update check.
These pin the CLI cold-start contract established in the sub-400ms pass:
- check_fn availability probes must not import the OpenAI SDK or build
real HTTP clients (aux_probe_mode).
- tools/mcp_tool must not import the `mcp` SDK at module import time.
- build_welcome_banner must not block on the update-check prefetch.
"""
import sys
import threading
import time
from unittest.mock import patch
import pytest
class TestAuxProbeMode:
def test_probe_mode_returns_stub_without_openai_import(self):
import agent.auxiliary_client as aux
with aux.aux_probe_mode():
client = aux._create_openai_client(api_key="k", base_url="https://x.invalid/v1")
assert isinstance(client, aux._AuxProbeClientStub)
assert client.api_key == "k"
def test_probe_stub_never_cached(self):
import agent.auxiliary_client as aux
stub = aux._AuxProbeClientStub()
key = ("probe-test", False, "", "", "", (), False, "", None, "m")
aux._store_cached_client(key, stub, "m")
with aux._client_cache_lock:
assert key not in aux._client_cache
def test_probe_stub_raises_on_runtime_use(self):
import agent.auxiliary_client as aux
stub = aux._AuxProbeClientStub()
with pytest.raises(RuntimeError, match="availability checks only"):
_ = stub.chat
def test_probe_mode_is_scoped_and_reentrant(self):
import agent.auxiliary_client as aux
assert not aux._aux_probe_active()
with aux.aux_probe_mode():
assert aux._aux_probe_active()
with aux.aux_probe_mode():
assert aux._aux_probe_active()
# inner exit must not clear the outer scope
assert aux._aux_probe_active()
assert not aux._aux_probe_active()
def test_probe_mode_is_thread_local(self):
import agent.auxiliary_client as aux
seen = {}
def other_thread():
seen["active"] = aux._aux_probe_active()
with aux.aux_probe_mode():
t = threading.Thread(target=other_thread)
t.start()
t.join()
assert seen["active"] is False
def test_maybe_wrap_anthropic_passes_stub_through(self):
import agent.auxiliary_client as aux
stub = aux._AuxProbeClientStub(base_url="https://api.anthropic.com")
out = aux._maybe_wrap_anthropic(stub, "m", "key", "https://api.anthropic.com")
assert out is stub
def test_to_async_client_passes_stub_through(self):
import agent.auxiliary_client as aux
stub = aux._AuxProbeClientStub()
client, model = aux._to_async_client(stub, "m")
assert client is stub
assert model == "m"
class TestVisionCheckUsesProbeMode:
def test_check_vision_requirements_enters_probe_mode(self):
from tools import vision_tools
import agent.auxiliary_client as aux
states = []
def fake_resolver(*a, **k):
states.append(aux._aux_probe_active())
return ("nous", aux._AuxProbeClientStub(), "m")
with patch.object(aux, "resolve_vision_provider_client", fake_resolver):
assert vision_tools.check_vision_requirements() is True
assert states and all(states)
class TestLazyMcpSdk:
def test_module_import_does_not_import_mcp_sdk(self):
"""Importing tools.mcp_tool must not pull in the `mcp` package."""
import subprocess
code = (
"import sys; sys.modules.pop('mcp', None); "
"import tools.mcp_tool; "
"assert 'mcp' not in sys.modules, 'mcp imported eagerly'; "
"print('ok')"
)
proc = subprocess.run(
[sys.executable, "-c", code],
capture_output=True, text=True, timeout=120,
)
assert proc.returncode == 0, proc.stderr
assert "ok" in proc.stdout
def test_availability_flag_reflects_find_spec(self):
import importlib.util
from tools import mcp_tool
expected = importlib.util.find_spec("mcp") is not None
assert mcp_tool._MCP_AVAILABLE is expected
def test_ensure_mcp_sdk_binds_symbols(self):
import importlib.util
from tools import mcp_tool
if importlib.util.find_spec("mcp") is None:
pytest.skip("mcp SDK not installed")
assert mcp_tool._ensure_mcp_sdk() is True
assert mcp_tool.ClientSession is not None
assert mcp_tool.stdio_client is not None
def test_ensure_respects_patched_unavailable(self):
from tools import mcp_tool
with patch.object(mcp_tool, "_MCP_AVAILABLE", False):
assert mcp_tool._ensure_mcp_sdk() is False
def test_lazy_symbol_getattr_resolves_via_ensure(self):
import importlib.util
from tools import mcp_tool
if importlib.util.find_spec("mcp") is None:
pytest.skip("mcp SDK not installed")
# getattr through the module (what mock.patch does when saving the
# original) must materialize the symbol instead of AttributeError.
assert getattr(mcp_tool, "StdioServerParameters") is not None
class TestBannerUpdateCheckNonBlocking:
def test_banner_does_not_block_on_pending_update_check(self):
"""When the prefetch hasn't finished, the banner path must return in
well under the old 500ms blocking wait."""
import hermes_cli.banner as banner
class _NullConsole:
def print(self, *a, **k):
pass
with patch.object(banner, "_update_check_done", threading.Event()), \
patch.object(banner, "_deferred_update_notice_started", False):
start = time.perf_counter()
behind = banner.get_update_result(timeout=0.05)
if behind is None and not banner._update_check_done.is_set():
banner._defer_update_notice(_NullConsole())
elapsed = time.perf_counter() - start
assert elapsed < 0.3, f"banner update check blocked {elapsed:.3f}s"
def test_deferred_notice_prints_when_result_lands(self):
import hermes_cli.banner as banner
printed = []
class _Console:
def print(self, msg, *a, **k):
printed.append(msg)
done = threading.Event()
with patch.object(banner, "_update_check_done", done), \
patch.object(banner, "_update_result", None), \
patch.object(banner, "_deferred_update_notice_started", False):
banner._defer_update_notice(_Console(), max_wait=5.0)
banner._update_result = 3
done.set()
deadline = time.time() + 5
while not printed and time.time() < deadline:
time.sleep(0.02)
assert printed, "deferred update notice never printed"
assert "3 commits behind" in printed[0]
def test_deferred_notice_silent_when_up_to_date(self):
import hermes_cli.banner as banner
printed = []
class _Console:
def print(self, msg, *a, **k):
printed.append(msg)
done = threading.Event()
with patch.object(banner, "_update_check_done", done), \
patch.object(banner, "_update_result", 0), \
patch.object(banner, "_deferred_update_notice_started", False):
banner._defer_update_notice(_Console(), max_wait=2.0)
done.set()
time.sleep(0.3)
assert not printed

View File

@ -212,74 +212,166 @@ _MCP_SAMPLING_TYPES = False
_MCP_NOTIFICATION_TYPES = False
_MCP_ELICITATION_TYPES = False
_MCP_MESSAGE_HANDLER_SUPPORTED = False
_MCP_LOGGING_CALLBACK_SUPPORTED = False
_MCP_NEW_HTTP = False
sse_client = None
# Conservative fallback for SDK builds that don't export LATEST_PROTOCOL_VERSION.
# Streamable HTTP was introduced by 2025-03-26, so this remains valid for the
# HTTP transport path even on older-but-supported SDK versions.
LATEST_PROTOCOL_VERSION = "2025-03-26"
# The heavy SDK import is LAZY (see _ensure_mcp_sdk): importing `mcp` costs
# ~260ms (mcp.types alone is ~60ms of pydantic model construction), which used
# to be paid at tool-discovery time on EVERY CLI startup even with zero MCP
# servers configured. Availability is decided here with a metadata-only
# find_spec probe (~1ms, no module execution) so every existing
# `if not _MCP_AVAILABLE` gate, test patch, and skipif keeps its exact
# semantics; the symbol import itself happens on first real SDK use.
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
_MCP_AVAILABLE = True
try:
from mcp.client.streamable_http import streamablehttp_client
_MCP_HTTP_AVAILABLE = True
except ImportError:
_MCP_HTTP_AVAILABLE = False
# Prefer the non-deprecated API (mcp >= 1.24.0); fall back to the
# deprecated wrapper for older SDK versions.
try:
from mcp.client.streamable_http import streamable_http_client
_MCP_NEW_HTTP = True
except ImportError:
_MCP_NEW_HTTP = False
try:
from mcp.types import LATEST_PROTOCOL_VERSION
except ImportError:
logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version")
# SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP)
try:
from mcp.client.sse import sse_client
except ImportError:
sse_client = None
logger.debug("mcp.client.sse.sse_client not available -- SSE transport disabled")
# Sampling types -- separated so older SDK versions don't break MCP support
try:
from mcp.types import (
CreateMessageResult,
CreateMessageResultWithTools,
ErrorData,
SamplingCapability,
SamplingToolsCapability,
TextContent,
ToolUseContent,
)
_MCP_SAMPLING_TYPES = True
except ImportError:
logger.debug("MCP sampling types not available -- sampling disabled")
# Elicitation types -- gated separately for the same reason as sampling.
# Added in mcp Python SDK 1.11.0 (Jul 2025); servers use elicitation to
# ask the client for structured input mid-tool-call (e.g. payment
# authorization). Missing types just disable the feature; everything
# else keeps working.
try:
from mcp.types import ElicitRequestParams, ElicitResult
_MCP_ELICITATION_TYPES = True
except ImportError:
logger.debug("MCP elicitation types not available -- elicitation disabled")
# Notification types for dynamic tool discovery (tools/list_changed)
try:
from mcp.types import (
ServerNotification,
ToolListChangedNotification,
PromptListChangedNotification,
ResourceListChangedNotification,
)
_MCP_NOTIFICATION_TYPES = True
except ImportError:
logger.debug("MCP notification types not available -- dynamic tool discovery disabled")
except ImportError:
import importlib.util as _importlib_util
_MCP_AVAILABLE = _importlib_util.find_spec("mcp") is not None
except Exception:
_MCP_AVAILABLE = False
if not _MCP_AVAILABLE:
logger.debug("mcp package not installed -- MCP tool support disabled")
ClientSession: Any = None
_MCP_SDK_IMPORT_ATTEMPTED = False
_MCP_SDK_IMPORT_LOCK = threading.Lock()
# SDK symbols that _ensure_mcp_sdk() binds on first use. Module-level
# __getattr__ (PEP 562) below resolves external access to any of these by
# importing the SDK first — so tests doing mock.patch("tools.mcp_tool.
# stdio_client", ...) trigger the import when patch() saves the original,
# and the subsequent mock is never clobbered (_ensure is idempotent).
_MCP_SDK_LAZY_SYMBOLS = frozenset({
"StdioServerParameters", "stdio_client",
"streamablehttp_client", "streamable_http_client",
"CreateMessageResult", "CreateMessageResultWithTools", "ErrorData",
"SamplingCapability", "SamplingToolsCapability", "TextContent",
"ToolUseContent", "ElicitRequestParams", "ElicitResult",
"ServerNotification", "ToolListChangedNotification",
"PromptListChangedNotification", "ResourceListChangedNotification",
})
def __getattr__(name: str):
if name in _MCP_SDK_LAZY_SYMBOLS:
_ensure_mcp_sdk()
try:
return globals()[name]
except KeyError:
pass # SDK missing or symbol absent on this SDK build
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _ensure_mcp_sdk() -> bool:
"""Import the optional ``mcp`` SDK on first use. Returns availability.
Idempotent and thread-safe. Sets the module-level ``_MCP_*`` flags and
SDK symbol globals exactly as the old import-time block did. Honors a
test-patched ``_MCP_AVAILABLE=False`` (returns False without importing)
and test-installed mock symbols (``ClientSession`` already set no
re-import, so mocks are never clobbered).
"""
global _MCP_SDK_IMPORT_ATTEMPTED, _MCP_AVAILABLE, _MCP_HTTP_AVAILABLE
global _MCP_SAMPLING_TYPES, _MCP_NOTIFICATION_TYPES, _MCP_ELICITATION_TYPES
global _MCP_MESSAGE_HANDLER_SUPPORTED, _MCP_LOGGING_CALLBACK_SUPPORTED
global _MCP_NEW_HTTP, LATEST_PROTOCOL_VERSION, sse_client
global ClientSession, StdioServerParameters, stdio_client
global streamablehttp_client, streamable_http_client
global CreateMessageResult, CreateMessageResultWithTools, ErrorData
global SamplingCapability, SamplingToolsCapability, TextContent, ToolUseContent
global ElicitRequestParams, ElicitResult
global ServerNotification, ToolListChangedNotification
global PromptListChangedNotification, ResourceListChangedNotification
if not _MCP_AVAILABLE:
return False
if _MCP_SDK_IMPORT_ATTEMPTED or ClientSession is not None:
return _MCP_AVAILABLE
with _MCP_SDK_IMPORT_LOCK:
if _MCP_SDK_IMPORT_ATTEMPTED or ClientSession is not None:
return _MCP_AVAILABLE
try:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
_MCP_AVAILABLE = True
try:
from mcp.client.streamable_http import streamablehttp_client
_MCP_HTTP_AVAILABLE = True
except ImportError:
_MCP_HTTP_AVAILABLE = False
# Prefer the non-deprecated API (mcp >= 1.24.0); fall back to the
# deprecated wrapper for older SDK versions.
try:
from mcp.client.streamable_http import streamable_http_client
_MCP_NEW_HTTP = True
except ImportError:
_MCP_NEW_HTTP = False
try:
from mcp.types import LATEST_PROTOCOL_VERSION
except ImportError:
logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version")
# SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP)
try:
from mcp.client.sse import sse_client
except ImportError:
sse_client = None
logger.debug("mcp.client.sse.sse_client not available -- SSE transport disabled")
# Sampling types -- separated so older SDK versions don't break MCP support
try:
from mcp.types import (
CreateMessageResult,
CreateMessageResultWithTools,
ErrorData,
SamplingCapability,
SamplingToolsCapability,
TextContent,
ToolUseContent,
)
_MCP_SAMPLING_TYPES = True
except ImportError:
logger.debug("MCP sampling types not available -- sampling disabled")
# Elicitation types -- gated separately for the same reason as sampling.
# Added in mcp Python SDK 1.11.0 (Jul 2025); servers use elicitation to
# ask the client for structured input mid-tool-call (e.g. payment
# authorization). Missing types just disable the feature; everything
# else keeps working.
try:
from mcp.types import ElicitRequestParams, ElicitResult
_MCP_ELICITATION_TYPES = True
except ImportError:
logger.debug("MCP elicitation types not available -- elicitation disabled")
# Notification types for dynamic tool discovery (tools/list_changed)
try:
from mcp.types import (
ServerNotification,
ToolListChangedNotification,
PromptListChangedNotification,
ResourceListChangedNotification,
)
_MCP_NOTIFICATION_TYPES = True
except ImportError:
logger.debug("MCP notification types not available -- dynamic tool discovery disabled")
except ImportError:
logger.debug("mcp package not installed -- MCP tool support disabled")
if _MCP_AVAILABLE:
try:
from mcp.types import METHOD_NOT_FOUND as _mnf
global _JSONRPC_METHOD_NOT_FOUND
_JSONRPC_METHOD_NOT_FOUND = _mnf
except Exception: # pragma: no cover — SDK without the constant
pass
_MCP_MESSAGE_HANDLER_SUPPORTED = _check_message_handler_support()
if _MCP_AVAILABLE and not _MCP_MESSAGE_HANDLER_SUPPORTED:
logger.debug("MCP SDK does not support message_handler -- dynamic tool discovery disabled")
_MCP_LOGGING_CALLBACK_SUPPORTED = _check_logging_callback_support()
_MCP_SDK_IMPORT_ATTEMPTED = True
return _MCP_AVAILABLE
def _check_message_handler_support() -> bool:
"""Check if ClientSession accepts ``message_handler`` kwarg.
@ -295,11 +387,6 @@ def _check_message_handler_support() -> bool:
return False
_MCP_MESSAGE_HANDLER_SUPPORTED = _check_message_handler_support()
if _MCP_AVAILABLE and not _MCP_MESSAGE_HANDLER_SUPPORTED:
logger.debug("MCP SDK does not support message_handler -- dynamic tool discovery disabled")
def _check_logging_callback_support() -> bool:
"""Check if ClientSession accepts the ``logging_callback`` kwarg.
@ -316,8 +403,6 @@ def _check_logging_callback_support() -> bool:
return False
_MCP_LOGGING_CALLBACK_SUPPORTED = _check_logging_callback_support()
# MCP logging levels (RFC 5424 syslog severities) -> Python logging levels.
# Port of anomalyco/opencode#34529's serverLog mapping.
_MCP_LOG_LEVEL_MAP = {
@ -546,12 +631,10 @@ def _exc_str(exc: BaseException) -> str:
# JSON-RPC "method not found" — the error a server returns when it does not
# implement a requested method (e.g. a tool-capable server that never wired up
# the optional ``ping`` utility). Defined locally with a fallback so detection
# works even on SDK builds that don't export the constant.
try:
from mcp.types import METHOD_NOT_FOUND as _JSONRPC_METHOD_NOT_FOUND
except Exception: # pragma: no cover — older/newer SDK without the constant
_JSONRPC_METHOD_NOT_FOUND = -32601
# the optional ``ping`` utility). -32601 is the JSON-RPC 2.0 spec constant;
# _ensure_mcp_sdk() overrides it from mcp.types when the SDK is loaded (kept
# lazy so this module never triggers the ~260ms `mcp` import at import time).
_JSONRPC_METHOD_NOT_FOUND = -32601
def _is_method_not_found_error(exc: BaseException) -> bool:
@ -2537,7 +2620,7 @@ class MCPServerTask:
"MCP server '%s': identity_header is only supported on "
"HTTP/SSE transports — ignored for stdio servers", self.name,
)
if not _MCP_AVAILABLE:
if not _ensure_mcp_sdk():
raise ImportError(
f"MCP server '{self.name}' requires the 'mcp' Python SDK, but "
"it is not installed. Run `hermes setup` to install MCP support, "
@ -2913,6 +2996,7 @@ class MCPServerTask:
async def _run_http(self, config: dict):
"""Run the server using HTTP/StreamableHTTP transport."""
_ensure_mcp_sdk()
if not _MCP_HTTP_AVAILABLE:
raise ImportError(
f"MCP server '{self.name}' requires HTTP transport but "
@ -3246,6 +3330,11 @@ class MCPServerTask:
self._idle_timeout_seconds = _get_lifecycle_seconds(config, "idle_timeout_seconds")
self._max_lifetime_seconds = _get_lifecycle_seconds(config, "max_lifetime_seconds")
# Bind the lazily-imported SDK before reading feature flags below
# (_MCP_SAMPLING_TYPES / _MCP_ELICITATION_TYPES are False until the
# SDK import actually runs).
_ensure_mcp_sdk()
# Set up sampling handler if enabled and SDK types are available
sampling_config = config.get("sampling", {})
if sampling_config.get("enabled", True) and _MCP_SAMPLING_TYPES:
@ -6640,7 +6729,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
Returns:
List of all currently registered MCP tool names.
"""
if not _MCP_AVAILABLE:
if not _ensure_mcp_sdk():
logger.debug("MCP SDK not available -- skipping explicit MCP registration")
return []
@ -6854,15 +6943,17 @@ def discover_mcp_tools() -> List[str]:
Returns:
List of all registered MCP tool names.
"""
if not _MCP_AVAILABLE:
logger.debug("MCP SDK not available -- skipping MCP tool discovery")
return []
servers = _load_mcp_config()
if not servers:
logger.debug("No MCP servers configured")
return []
# SDK import is deferred to HERE so a config with zero MCP servers (the
# default) never pays the ~260ms `mcp` import on CLI startup.
if not _ensure_mcp_sdk():
logger.debug("MCP SDK not available -- skipping MCP tool discovery")
return []
# Cross-process discovery guard (#62771). A lock loser waits for
# the holder, then performs its own process-local discovery. If locking is
# unavailable or the bounded wait expires, preserve the previous
@ -7035,7 +7126,7 @@ def probe_mcp_server_tools() -> Dict[str, List[tuple]]:
Dict mapping server name to list of (tool_name, description) tuples.
Servers that fail to connect are omitted from the result.
"""
if not _MCP_AVAILABLE:
if not _ensure_mcp_sdk():
return {}
servers_config = _load_mcp_config()

View File

@ -37,6 +37,7 @@ Usage:
import asyncio
import base64
import datetime
import importlib.util
import json
import logging
import os
@ -3727,15 +3728,11 @@ def check_tts_requirements() -> bool:
return False
return bool(_resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs"))
if provider == "openai":
try:
_import_openai_client()
except ImportError:
if importlib.util.find_spec("openai") is None:
return False
return _has_openai_audio_backend()
if provider == "deepinfra":
try:
_import_openai_client()
except ImportError:
if importlib.util.find_spec("openai") is None:
return False
return bool(_resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra"))
if provider == "minimax":

View File

@ -1634,17 +1634,21 @@ def check_vision_requirements() -> bool:
when the auto chain would have served the request (issue #31179).
"""
try:
from agent.auxiliary_client import resolve_vision_provider_client
from agent.auxiliary_client import aux_probe_mode, resolve_vision_provider_client
except ImportError:
return False
try:
_provider, client, _model = resolve_vision_provider_client()
if client is not None:
return True
# Same fallback to "auto" that call_llm performs when the configured
# provider can't be resolved.
_provider, client, _model = resolve_vision_provider_client(provider="auto")
return client is not None
# Probe mode answers "is a vision client resolvable?" without paying
# for real SDK client construction (openai import + httpx/SSL setup)
# on the tool-gating path — resolution policy is identical.
with aux_probe_mode():
_provider, client, _model = resolve_vision_provider_client()
if client is not None:
return True
# Same fallback to "auto" that call_llm performs when the configured
# provider can't be resolved.
_provider, client, _model = resolve_vision_provider_client(provider="auto")
return client is not None
except Exception:
return False