Cron pre-script Python interpreter handling and imports
NousResearch/hermes-agent · cluster 8714 · cron
Data as of
Nothing open on GitHub. Fix merged in #17046. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #17046. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #125392. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address how cron pre-run scripts select their Python interpreter, including shebang respect, configurable interpreter support, and module import errors caused by interpreter mismatches. Related lazy-import work for startup performance is also included.
Open issues0
Open PRs0
Fixes on record
-
#17046 perf(startup): lazy-import OpenAI, Anthropic, Firecrawl, account_usage
files:
agent/anthropic_adapter.py,agent/auxiliary_client.py,cli.py,gateway/run.py,run_agent.py,tools/web_tools.pyShow the diff
-
agent/anthropic_adapter.py@@ -22,10 +22,25 @@ from typing import Any, Dict, List, Optional, Tuple from utils import normalize_proxy_env_vars -try: - import anthropic as _anthropic_sdk -except ImportError: - _anthropic_sdk = None # type: ignore[assignment] +# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls +# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) +# and the 3 usage sites (build_anthropic_client, build_anthropic_bedrock_client, +# read_claude_code_credentials_from_keychain) are all on cold user-triggered +# paths. Access via the `_get_anthropic_sdk()` accessor below, which caches +# the module after the first call and returns None on ImportError. +_anthropic_sdk: Any = ... # sentinel — None means "tried and missing" + + +def _get_anthropic_sdk(): + """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" + global _anthropic_sdk + if _anthropic_sdk is ...: + try: + import anthropic as _sdk + _anthropic_sdk = _sdk + except ImportError: + _anthropic_sdk = None + return _anthropic_sdk logger = logging.getLogger(__name__) @@ -395,6 +410,7 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = Returns an anthropic.Anthropic instance. """ + _anthropic_sdk = _get_anthropic_sdk() if _anthropic_sdk is None: raise ImportError( "The 'anthropic' package is required for the Anthropic provider. " @@ -492,6 +508,7 @@ def build_anthropic_bedrock_client(region: str): Auth uses the boto3 default credential chain (IAM roles, SSO, env vars). """ + _anthropic_sdk = _get_anthropic_sdk() if _anthropic_sdk is None: raise ImportError( "The 'anthropic' package is required for the Bedrock provider. " -
agent/auxiliary_client.py@@ -41,10 +41,57 @@ import time from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse -from openai import OpenAI +# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the +# openai SDK pulls a large type tree (~240 ms cold, including responses/*, +# graders/*). We expose `OpenAI` here as a thin proxy that imports the SDK on +# first call and forwards, so: +# (a) the 15+ in-module `OpenAI(...)` construction sites work unchanged +# (Python's function-scope name lookup resolves `OpenAI` to the proxy +# object bound in module globals here, without triggering any import); +# (b) external code can still do `auxiliary_client.OpenAI` or +# `patch("agent.auxiliary_client.OpenAI", ...)` — tests see the proxy, +# and patch replaces the module attribute as usual; +# (c) `OpenAI` as a type annotation resolves at runtime to the proxy class +# (which is harmless — annotations aren't type-checked at runtime). +# See tests/agent/test_auxiliary_client.py for patch patterns this supports. +if TYPE_CHECKING: + from openai import OpenAI # noqa: F401 — type hints only + +_OPENAI_CLS_CACHE: Optional[type] = None + + +def _load_openai_cls() -> type: + """Import and cache ``openai.OpenAI``.""" + global _OPENAI_CLS_CACHE + if _OPENAI_CLS_CACHE is None: + from openai import OpenAI as _cls + _OPENAI_CLS_CACHE = _cls + return _OPENAI_CLS_CACHE + + +class _OpenAIProxy: + """Module-level proxy that looks like the ``openai.OpenAI`` class. + + Forwards ``OpenAI(...)`` calls and ``isinstance(x, OpenAI)`` checks to the + real SDK class, importing the SDK lazily on first use. + """ + + __slots__ = () + + def __call__(self, *args, **kwargs): + return _load_openai_cls()(*args, **kwargs) + + def __instancecheck__(self, obj): + return isinstance(obj, _load_openai_cls()) + + def __repr__(self): + return "<lazy openai.OpenAI proxy>" + + +OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home -
cli.py@@ -69,7 +69,9 @@ format_duration_compact, format_token_count_compact, ) -from agent.account_usage import fetch_account_usage, render_account_usage_lines +# NOTE: `from agent.account_usage import ...` is deliberately NOT at module +# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only +# needed when the user runs `/limits`. Lazy-imported inside the handler below. from hermes_cli.banner import _format_context_length, format_banner_version_label _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") @@ -7285,6 +7287,8 @@ def _show_usage(self): provider = getattr(agent, "provider", None) or getattr(self, "provider", None) base_url = getattr(agent, "base_url", None) or getattr(self, "base_url", None) api_key = [redacted:secret-assignment] "api_key", None) or getattr(self, "api_key", None) + # Lazy import — pulls the OpenAI SDK chain, only needed here. + from agent.account_usage import fetch_account_usage, render_account_usage_lines account_snapshot = None if provider: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool: -
gateway/run.py@@ -31,6 +31,12 @@ from datetime import datetime from typing import Dict, Optional, Any, List +# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by +# /usage; we still import it at module top in the gateway because test +# patches (tests/gateway/test_usage_command.py) target +# `gateway.run.fetch_account_usage` as a module-level attribute. The +# gateway is a long-running daemon, so its boot cost matters less than +# preserving the established test-patch surface. from agent.account_usage import fetch_account_usage, render_account_usage_lines # --- Agent cache tuning --------------------------------------------------- -
run_agent.py@@ -41,13 +41,48 @@ import uuid from typing import List, Dict, Any, Optional from urllib.parse import urlparse, parse_qs, urlunparse -from openai import OpenAI +# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the +# SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object +# that imports the SDK on first call/isinstance check. This preserves: +# (a) the single in-module `OpenAI(**client_kwargs)` call site at +# _create_openai_client, and +# (b) `patch("run_agent.OpenAI", ...)` test patterns used by ~28 test files. import fire from datetime import datetime from pathlib import Path from hermes_constants import get_hermes_home + +_OPENAI_CLS_CACHE: Optional[type] = None + + +def _load_openai_cls() -> type: + """Import and cache ``openai.OpenAI``.""" + global _OPENAI_CLS_CACHE + if _OPENAI_CLS_CACHE is None: + from openai import OpenAI as _cls + _OPENAI_CLS_CACHE = _cls + return _OPENAI_CLS_CACHE + + +class _OpenAIProxy: + """Module-level proxy that looks like ``openai.OpenAI`` but imports lazily.""" + + __slots__ = () + + def __call__(self, *args, **kwargs): + return _load_openai_cls()(*args, **kwargs) + + def __instancecheck__(self, obj): + return isinstance(obj, _load_openai_cls()) + + def __repr__(self): + return "<lazy openai.OpenAI proxy>" + + +OpenAI = _OpenAIProxy() + # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.env_loader import load_hermes_dotenv @@ -5243,6 +5278,8 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", "")) if keepalive_http is not None: client_kwargs["http_client"] = keepalive_http + # Uses the module-level `OpenAI` name, resolved lazily on first + # access via __getattr__ below. Tests patch via `run_agent.OpenAI`. client = OpenAI(**client_kwargs) logger.info( "OpenAI client created (%s, shared=%s) %s", -
tools/web_tools.py@@ -47,7 +47,10 @@ import asyncio from typing import List, Dict, Any, Optional import httpx -from firecrawl import Firecrawl +# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top — +# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and +# we only need it when the backend is actually "firecrawl". See +# _get_firecrawl_client() below for the lazy import. from agent.auxiliary_client import ( async_call_llm, extract_content_or_reasoning, @@ -236,6 +239,8 @@ def _get_firecrawl_client(): if _firecrawl_client is not None and _firecrawl_client_config == client_config: return _firecrawl_client + # Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used. + from firecrawl import Firecrawl # noqa: E402 _firecrawl_client = Firecrawl(**kwargs) _firecrawl_client_config = client_config return _firecrawl_client
-
-
#125392 feat(cron): per-job Python interpreter for cron scripts (salvage #70500)
files:
cron/jobs.py,cron/monitor.py,cron/scheduler_prompt.py,cron/scheduler_script.py,hermes_cli/cron.py,hermes_cli/subcommands/cron.py,tests/cron/test_cron_no_agent.py,tests/cron/test_cron_script.py,tests/cron/test_cron_workdir.py,tests/cron/test_monitor_kind.py,tools/cronjob_job_args.py,tools/cronjob_tools.py,website/docs/guides/cron-script-only.md,website/docs/user-guide/features/cron.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/cron-script-only.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md
4 PRs closed without merging
From the close comments: done elsewhere 2 · superseded by another PR 1 · other 1
-
#8741 feat(cron): allow configurable Python interpreter for pre-scripts · done elsewhere →
5006b22,463ec1d -
#11869 fix(cron): preflight import check with clear error on interpreter mismatch (#11610) · done elsewhere →
b5128a7,#17046 - #12196 fix(cron): respect shebang in pre-run scripts instead of forcing sys.executable · other
-
#70500 fix(cron): allow Python scripts to use an external interpreter · superseded by another PR →
#125392
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab