mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
perf(imports): lazy-load heavy SDKs off the cold-start waterfall
Four deferrals following the established truthy-skip / PEP 562 lazy-load patterns (PRs #22681/#22859 lineage). Rebased over #74194, which independently landed the browser_tool half of this work — that file is dropped here; the remaining four modules are untouched by it: - tools/vision_tools.py: defer agent.auxiliary_client (credential_pool -> hermes_cli.auth -> httpx -> rich, ~50 ms) to first vision handler call. async_call_llm / extract_content_or_reasoning stay patchable module attributes; injected test mocks win over the loader. - agent/model_metadata.py: defer 'requests' (+urllib3, ~27 ms of the 'import cli' waterfall) to the fetch functions. PEP 562 __getattr__ keeps patch('agent.model_metadata.requests.get') working. - tools/browser_supervisor.py: websockets (~22 ms) imports on first CDP connect; ClientConnection type under TYPE_CHECKING. - cron/jobs.py: croniter (~15 ms) resolves on first cron-expression use; HAS_CRONITER stays monkeypatchable (None = unprobed sentinel). A/B vs current main incl. #74194 (median of 7, cold subprocess): import cli 147 -> 132 ms (-10%) import model_tools 244 -> 224 ms (-8%) import run_agent 264 -> 244 ms (-8%) Lazy-verify: importing the four modules no longer pulls requests / croniter / websockets into sys.modules. 369 targeted tests green post-rebase.
This commit is contained in:
parent
2006cd5895
commit
bc747001ee
4 changed files with 82 additions and 14 deletions
|
|
@ -13,18 +13,40 @@ import os
|
|||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover — runtime import is lazy (see below)
|
||||
import requests
|
||||
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ``requests`` (with urllib3) costs ~27 ms of the `import cli` waterfall and
|
||||
# is only used inside the fetch functions below. It's resolved lazily:
|
||||
# ``_ensure_requests()`` populates the module global on the runtime path, and
|
||||
# the PEP 562 ``__getattr__`` covers external attribute access — notably
|
||||
# ``patch("agent.model_metadata.requests.get")`` in tests, which resolves the
|
||||
# attribute at patch time.
|
||||
|
||||
|
||||
def _ensure_requests():
|
||||
if "requests" not in globals():
|
||||
import requests as _requests
|
||||
globals()["requests"] = _requests
|
||||
return globals()["requests"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "requests":
|
||||
return _ensure_requests()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _resolve_requests_verify() -> bool | str:
|
||||
"""Resolve SSL verify setting for `requests` calls from env vars.
|
||||
|
|
@ -995,6 +1017,7 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
|||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
_ensure_requests()
|
||||
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
|
||||
# retry stage through proxies that 403 CONNECT, ballooning to minutes
|
||||
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
|
||||
|
|
@ -1051,6 +1074,7 @@ def fetch_endpoint_model_metadata(
|
|||
normalized = _normalize_base_url(base_url)
|
||||
if not normalized or _is_openrouter_base_url(normalized):
|
||||
return {}
|
||||
_ensure_requests()
|
||||
|
||||
if not force_refresh:
|
||||
cached = _endpoint_model_metadata_cache.get(normalized)
|
||||
|
|
@ -1962,6 +1986,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
|
|||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
_ensure_requests()
|
||||
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
|
@ -2069,6 +2094,7 @@ def _fetch_codex_oauth_context_lengths_with_source(
|
|||
headers["ChatGPT-Account-Id"] = acct_id
|
||||
|
||||
try:
|
||||
_ensure_requests()
|
||||
resp = requests.get(
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||
headers=headers,
|
||||
|
|
|
|||
30
cron/jobs.py
30
cron/jobs.py
|
|
@ -41,11 +41,25 @@ logger = logging.getLogger(__name__)
|
|||
from hermes_time import now as _hermes_now
|
||||
from utils import atomic_replace, atomic_write_text
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
HAS_CRONITER = True
|
||||
except ImportError:
|
||||
HAS_CRONITER = False
|
||||
# ``croniter`` compiles ~15 ms of regexes at import and only matters for
|
||||
# 5-field cron expressions. Resolve lazily; ``HAS_CRONITER`` stays a module
|
||||
# attribute (tests monkeypatch it, and a monkeypatched value wins because
|
||||
# ``_ensure_croniter`` only probes while it's still None).
|
||||
croniter = None
|
||||
HAS_CRONITER: Optional[bool] = None
|
||||
|
||||
|
||||
def _ensure_croniter() -> bool:
|
||||
"""Import croniter on first use; honor a pre-set HAS_CRONITER override."""
|
||||
global croniter, HAS_CRONITER
|
||||
if HAS_CRONITER is None:
|
||||
try:
|
||||
from croniter import croniter as _croniter
|
||||
croniter = _croniter
|
||||
HAS_CRONITER = True
|
||||
except ImportError:
|
||||
HAS_CRONITER = False
|
||||
return bool(HAS_CRONITER)
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
|
|
@ -585,7 +599,7 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:
|
|||
if len(parts) >= 5 and all(
|
||||
re.match(r'^[\d\*\-,/]+$', p) for p in parts[:5]
|
||||
):
|
||||
if not HAS_CRONITER:
|
||||
if not _ensure_croniter():
|
||||
raise ValueError("Cron expressions require 'croniter' package. Install with: pip install croniter")
|
||||
# Validate cron expression
|
||||
try:
|
||||
|
|
@ -738,7 +752,7 @@ def _compute_grace_seconds(schedule: dict) -> int:
|
|||
grace = period_seconds // 2
|
||||
return max(MIN_GRACE, min(grace, MAX_GRACE))
|
||||
|
||||
if kind == "cron" and HAS_CRONITER:
|
||||
if kind == "cron" and _ensure_croniter():
|
||||
expr = schedule.get("expr")
|
||||
if expr:
|
||||
try:
|
||||
|
|
@ -791,7 +805,7 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
|
|||
expr = schedule.get("expr")
|
||||
if not expr:
|
||||
return None
|
||||
if not HAS_CRONITER:
|
||||
if not _ensure_croniter():
|
||||
logger.warning(
|
||||
"Cannot compute next run for cron schedule %r: 'croniter' is "
|
||||
"not installed. croniter is a core dependency as of v0.9.x; "
|
||||
|
|
|
|||
|
|
@ -26,10 +26,14 @@ import logging
|
|||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
# ``websockets`` costs ~22 ms at import and is only needed when a supervisor
|
||||
# actually connects to a CDP endpoint (``_connect_ws``). With
|
||||
# ``from __future__ import annotations`` in force the ``ClientConnection``
|
||||
# annotation is string-only, so the type import stays under TYPE_CHECKING.
|
||||
if TYPE_CHECKING:
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -649,6 +653,7 @@ class CDPSupervisor:
|
|||
attempt = 0
|
||||
last_success_at = 0.0
|
||||
backoff = 0.5
|
||||
import websockets # deferred: only supervisors that connect pay the import
|
||||
while not self._stop_requested:
|
||||
try:
|
||||
self._ws = await asyncio.wait_for(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,28 @@ from pathlib import Path
|
|||
from typing import Any, Awaitable, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
import httpx
|
||||
from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
|
||||
|
||||
# ``agent.auxiliary_client`` pulls credential_pool → hermes_cli.auth → httpx
|
||||
# → rich (~50 ms cold); only vision handlers need it. Loaded lazily; both
|
||||
# names stay module attributes so tests can keep patching
|
||||
# ``tools.vision_tools.async_call_llm``. Truthy-skip: injected mocks win.
|
||||
async_call_llm: Any = None
|
||||
extract_content_or_reasoning: Any = None
|
||||
|
||||
|
||||
def _load_auxiliary_client() -> None:
|
||||
global async_call_llm, extract_content_or_reasoning
|
||||
if async_call_llm is None or extract_content_or_reasoning is None:
|
||||
from agent.auxiliary_client import (
|
||||
async_call_llm as _acl,
|
||||
extract_content_or_reasoning as _ecr,
|
||||
)
|
||||
if async_call_llm is None:
|
||||
async_call_llm = _acl
|
||||
if extract_content_or_reasoning is None:
|
||||
extract_content_or_reasoning = _ecr
|
||||
|
||||
|
||||
from hermes_constants import get_hermes_dir
|
||||
from tools.debug_helpers import DebugSession
|
||||
from tools.website_policy import check_website_access
|
||||
|
|
@ -1251,6 +1272,7 @@ async def vision_analyze_tool(
|
|||
}
|
||||
if model:
|
||||
call_kwargs["model"] = model
|
||||
_load_auxiliary_client()
|
||||
# Try full-size image first; on size-related rejection, downscale and retry.
|
||||
try:
|
||||
response = await async_call_llm(**call_kwargs)
|
||||
|
|
@ -1758,6 +1780,7 @@ async def video_analyze_tool(
|
|||
if model:
|
||||
call_kwargs["model"] = model
|
||||
|
||||
_load_auxiliary_client()
|
||||
response = await async_call_llm(**call_kwargs)
|
||||
analysis = extract_content_or_reasoning(response)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue