# NousResearch/hermes-agent · Cron pre-script Python interpreter handling and imports

## Open now

- 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.

## TL;DR

- 9 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #17046. Verify it addresses your case.
- **Recommended:** read PR #17046 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-125392`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #8714: Feature: allow cron pre-scripts to use a configurable Python interpreter
- #11610: \[Bug\] Intermittent cron failure: ModuleNotFoundError: No module named 'openai' (Python interpreter mismatch)
- #12195: cron: pre-run scripts ignore their own shebang due to sys.executable

## Prior & competing PRs

- **#8741: feat(cron): allow configurable Python interpreter for pre-scripts**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `5006b2204b329ab017c05c0822c50c13453237f2`, `463ec1dc16ffcfc77dddcfe420a3a80f12fd7e49`, `5006b2204b329ab017c05c0822c50c13453237f2`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12196: fix(cron): respect shebang in pre-run scripts instead of forcing sys.executable**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#17046: perf(startup): lazy-import OpenAI, Anthropic, Firecrawl, account_usage**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `agent/auxiliary_client.py`, `cli.py`, `gateway/run.py`, `run_agent.py`, `tools/web_tools.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -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__)
     
    ```

    ```diff
    @@ -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. "
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#11869: fix(cron): preflight import check with clear error on interpreter mismatch (#11610)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b5128a751b2f5b29c3285d98abdda3d45780870f`, `#17046`
- **#70500: fix(cron): allow Python scripts to use an external interpreter**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#125392`, `#125392`

## Fix status

- Merged to main in PR #17046 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-27`.
- Subsystem: `cron`
