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

0open issues
0open PRs
9items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #17046 perf(startup): lazy-import OpenAI, Anthropic, Firecrawl, account_usage

    merged to main

    files: agent/anthropic_adapter.py, agent/auxiliary_client.py, cli.py, gateway/run.py, run_agent.py, tools/web_tools.py

    Show 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)

    merged to main

    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
  • Closed#8714Feature: allow cron pre-scripts to use a configurable Python interpreter
  • Closed#11610[Bug] Intermittent cron failure: ModuleNotFoundError: No module named 'openai' (Python interpreter mismatch)
  • Closed#12195cron: pre-run scripts ignore their own shebang due to sys.executable
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.00 means 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

At a glance

Subsystem
cron
Merged PRs
#17046, #125392
Closed issues
3
Attempts
4 PRs closed without merging
Fix link checked
27 Sep 2026

For your agent

The same facts, typed.