From 55f9e472a06ef63b85e6161fd086121675be1e27 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:04:26 -0700 Subject: [PATCH] =?UTF-8?q?perf(cli):=20sub-400ms=20warm=20startup=20?= =?UTF-8?q?=E2=80=94=20probe-mode=20check=5Ffns,=20lazy=20MCP=20SDK,=20ban?= =?UTF-8?q?ner=20snapshot,=20parallel=20worktree=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/auxiliary_client.py | 108 +++++- cli.py | 328 +++++++++++++--- hermes_cli/auth.py | 42 +- hermes_cli/banner.py | 365 +++++++++++++++--- hermes_cli/cli_agent_setup_mixin.py | 5 + hermes_cli/main.py | 112 +++++- hermes_cli/plugins.py | 134 +++++++ hermes_cli/tools_config.py | 17 +- hermes_state_schema.py | 50 ++- model_tools.py | 18 + tests/cli/test_cli_preloaded_skills.py | 32 +- tests/hermes_cli/test_banner_skills.py | 29 ++ tests/tools/conftest.py | 20 + .../tools/test_startup_latency_regressions.py | 211 ++++++++++ tools/mcp_tool.py | 253 ++++++++---- tools/tts_tool.py | 9 +- tools/vision_tools.py | 20 +- 17 files changed, 1508 insertions(+), 245 deletions(-) create mode 100644 tests/tools/test_startup_latency_regressions.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6ead8eff6aadf..13bce3f884199 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 "" + + +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: diff --git a/cli.py b/cli.py index 8da0a2fed2058..0ad7c2ed8a917 100644 --- a/cli.py +++ b/cli.py @@ -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: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index cc64e5049a671..772e9606baa05 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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.` 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, diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 3f4f893be7561..e6e5551b0abba 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -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 `` 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 diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 25ec7804b9282..d037cf997aa8c 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -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() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ba6dc07206d62..1b203d8a8034a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index c6ed42d726e73..13ea0a32269a8 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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. diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 78503935bdfde..fa0793f0f671d 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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: diff --git a/hermes_state_schema.py b/hermes_state_schema.py index bb735ff72d89d..294664fc64396 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -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. diff --git a/model_tools.py b/model_tools.py index dda40405b7064..4c10995b71617 100644 --- a/model_tools.py +++ b/model_tools.py @@ -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, diff --git a/tests/cli/test_cli_preloaded_skills.py b/tests/cli/test_cli_preloaded_skills.py index 9dc5f4feede40..83f65be1f9090 100644 --- a/tests/cli/test_cli_preloaded_skills.py +++ b/tests/cli/test_cli_preloaded_skills.py @@ -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().""" diff --git a/tests/hermes_cli/test_banner_skills.py b/tests/hermes_cli/test_banner_skills.py index 6bd5137b1f7d5..85d028d191b49 100644 --- a/tests/hermes_cli/test_banner_skills.py +++ b/tests/hermes_cli/test_banner_skills.py @@ -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 diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index 494dd206a1e42..11969ad7b0fd5 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -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. diff --git a/tests/tools/test_startup_latency_regressions.py b/tests/tools/test_startup_latency_regressions.py new file mode 100644 index 0000000000000..c9fb3701ec2d3 --- /dev/null +++ b/tests/tools/test_startup_latency_regressions.py @@ -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 diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index dc8ca38f84d85..88f02f6f4d7cb 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -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() diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 84944d57836f4..812afa909fa98 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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": diff --git a/tools/vision_tools.py b/tools/vision_tools.py index e8d2091e4047a..e84daaff7ffe1 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -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