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:
parent
03fa32c92d
commit
55f9e472a0
|
|
@ -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
328
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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue