{"repo":"NousResearch/hermes-agent","public_id":"8714","display_label":"Cron pre-script Python interpreter handling and imports","summary":"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.","subsystem":"cron","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17046},"resolution":{"state":"partially_resolved","by":"pr-125392"},"freshness":{"state":"verified","last_verified_at":"2026-09-27T21:02:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":8714,"pr_number":125392,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11610,"pr_number":17046,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":8714,"title":"Feature: allow cron pre-scripts to use a configurable Python interpreter","url":null,"state":"closed"},{"kind":"issue","number":11610,"title":"[Bug] Intermittent cron failure: ModuleNotFoundError: No module named 'openai' (Python interpreter mismatch)","url":null,"state":"closed"},{"kind":"issue","number":12195,"title":"cron: pre-run scripts ignore their own shebang due to sys.executable","url":null,"state":"closed"},{"kind":"pr","number":8741,"title":"feat(cron): allow configurable Python interpreter for pre-scripts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5006b2204b329ab017c05c0822c50c13453237f2","number":0},{"kind":"commit","value":"463ec1dc16ffcfc77dddcfe420a3a80f12fd7e49","number":0},{"kind":"commit","value":"5006b2204b329ab017c05c0822c50c13453237f2","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11869,"title":"fix(cron): preflight import check with clear error on interpreter mismatch (#11610)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b5128a751b2f5b29c3285d98abdda3d45780870f","number":0},{"kind":"pr","value":"#17046","number":17046}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12196,"title":"fix(cron): respect shebang in pre-run scripts instead of forcing sys.executable","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17046,"title":"perf(startup): lazy-import OpenAI, Anthropic, Firecrawl, account_usage","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/auxiliary_client.py","cli.py","gateway/run.py","run_agent.py","tools/web_tools.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -22,10 +22,25 @@\n from typing import Any, Dict, List, Optional, Tuple\n from utils import normalize_proxy_env_vars\n \n-try:\n-    import anthropic as _anthropic_sdk\n-except ImportError:\n-    _anthropic_sdk = None  # type: ignore[assignment]\n+# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls\n+# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)\n+# and the 3 usage sites (build_anthropic_client, build_anthropic_bedrock_client,\n+# read_claude_code_credentials_from_keychain) are all on cold user-triggered\n+# paths. Access via the `_get_anthropic_sdk()` accessor below, which caches\n+# the module after the first call and returns None on ImportError.\n+_anthropic_sdk: Any = ...  # sentinel — None means \"tried and missing\"\n+\n+\n+def _get_anthropic_sdk():\n+    \"\"\"Return the ``anthropic`` SDK module, importing lazily. None if not installed.\"\"\"\n+    global _anthropic_sdk\n+    if _anthropic_sdk is ...:\n+        try:\n+            import anthropic as _sdk\n+            _anthropic_sdk = _sdk\n+        except ImportError:\n+            _anthropic_sdk = None\n+    return _anthropic_sdk\n \n logger = logging.getLogger(__name__)\n ","@@ -395,6 +410,7 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =\n \n     Returns an anthropic.Anthropic instance.\n     \"\"\"\n+    _anthropic_sdk = _get_anthropic_sdk()\n     if _anthropic_sdk is None:\n         raise ImportError(\n             \"The 'anthropic' package is required for the Anthropic provider. \"","@@ -492,6 +508,7 @@ def build_anthropic_bedrock_client(region: str):\n \n     Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).\n     \"\"\"\n+    _anthropic_sdk = _get_anthropic_sdk()\n     if _anthropic_sdk is None:\n         raise ImportError(\n             \"The 'anthropic' package is required for the Bedrock provider. \""]},{"file":"agent/auxiliary_client.py","hunks":["@@ -41,10 +41,57 @@\n import time\n from pathlib import Path  # noqa: F401 — used by test mocks\n from types import SimpleNamespace\n-from typing import Any, Dict, List, Optional, Tuple\n+from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING\n from urllib.parse import urlparse, parse_qs, urlunparse\n \n-from openai import OpenAI\n+# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the\n+# openai SDK pulls a large type tree (~240 ms cold, including responses/*,\n+# graders/*). We expose `OpenAI` here as a thin proxy that imports the SDK on\n+# first call and forwards, so:\n+#   (a) the 15+ in-module `OpenAI(...)` construction sites work unchanged\n+#       (Python's function-scope name lookup resolves `OpenAI` to the proxy\n+#       object bound in module globals here, without triggering any import);\n+#   (b) external code can still do `auxiliary_client.OpenAI` or\n+#       `patch(\"agent.auxiliary_client.OpenAI\", ...)` — tests see the proxy,\n+#       and patch replaces the module attribute as usual;\n+#   (c) `OpenAI` as a type annotation resolves at runtime to the proxy class\n+#       (which is harmless — annotations aren't type-checked at runtime).\n+# See tests/agent/test_auxiliary_client.py for patch patterns this supports.\n+if TYPE_CHECKING:\n+    from openai import OpenAI  # noqa: F401 — type hints only\n+\n+_OPENAI_CLS_CACHE: Optional[type] = None\n+\n+\n+def _load_openai_cls() -> type:\n+    \"\"\"Import and cache ``openai.OpenAI``.\"\"\"\n+    global _OPENAI_CLS_CACHE\n+    if _OPENAI_CLS_CACHE is None:\n+        from openai import OpenAI as _cls\n+        _OPENAI_CLS_CACHE = _cls\n+    return _OPENAI_CLS_CACHE\n+\n+\n+class _OpenAIProxy:\n+    \"\"\"Module-level proxy that looks like the ``openai.OpenAI`` class.\n+\n+    Forwards ``OpenAI(...)`` calls and ``isinstance(x, OpenAI)`` checks to the\n+    real SDK class, importing the SDK lazily on first use.\n+    \"\"\"\n+\n+    __slots__ = ()\n+\n+    def __call__(self, *args, **kwargs):\n+        return _load_openai_cls()(*args, **kwargs)\n+\n+    def __instancecheck__(self, obj):\n+        return isinstance(obj, _load_openai_cls())\n+\n+    def __repr__(self):\n+        return \"<lazy openai.OpenAI proxy>\"\n+\n+\n+OpenAI = _OpenAIProxy()  # module-level name, resolves lazily on call/isinstance\n \n from agent.credential_pool import load_pool\n from hermes_cli.config import get_hermes_home"]},{"file":"cli.py","hunks":["@@ -69,7 +69,9 @@\n     format_duration_compact,\n     format_token_count_compact,\n )\n-from agent.account_usage import fetch_account_usage, render_account_usage_lines\n+# NOTE: `from agent.account_usage import ...` is deliberately NOT at module\n+# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only\n+# needed when the user runs `/limits`. Lazy-imported inside the handler below.\n from hermes_cli.banner import _format_context_length, format_banner_version_label\n \n _COMMAND_SPINNER_FRAMES = (\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\")","@@ -7285,6 +7287,8 @@ def _show_usage(self):\n         provider = getattr(agent, \"provider\", None) or getattr(self, \"provider\", None)\n         base_url = getattr(agent, \"base_url\", None) or getattr(self, \"base_url\", None)\n         api_key = [redacted:secret-assignment] \"api_key\", None) or getattr(self, \"api_key\", None)\n+        # Lazy import — pulls the OpenAI SDK chain, only needed here.\n+        from agent.account_usage import fetch_account_usage, render_account_usage_lines\n         account_snapshot = None\n         if provider:\n             with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool:"]},{"file":"gateway/run.py","hunks":["@@ -31,6 +31,12 @@\n from datetime import datetime\n from typing import Dict, Optional, Any, List\n \n+# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by\n+# /usage; we still import it at module top in the gateway because test\n+# patches (tests/gateway/test_usage_command.py) target\n+# `gateway.run.fetch_account_usage` as a module-level attribute. The\n+# gateway is a long-running daemon, so its boot cost matters less than\n+# preserving the established test-patch surface.\n from agent.account_usage import fetch_account_usage, render_account_usage_lines\n \n # --- Agent cache tuning ---------------------------------------------------"]},{"file":"run_agent.py","hunks":["@@ -41,13 +41,48 @@\n import uuid\n from typing import List, Dict, Any, Optional\n from urllib.parse import urlparse, parse_qs, urlunparse\n-from openai import OpenAI\n+# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the\n+# SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object\n+# that imports the SDK on first call/isinstance check. This preserves:\n+#   (a) the single in-module `OpenAI(**client_kwargs)` call site at\n+#       _create_openai_client, and\n+#   (b) `patch(\"run_agent.OpenAI\", ...)` test patterns used by ~28 test files.\n import fire\n from datetime import datetime\n from pathlib import Path\n \n from hermes_constants import get_hermes_home\n \n+\n+_OPENAI_CLS_CACHE: Optional[type] = None\n+\n+\n+def _load_openai_cls() -> type:\n+    \"\"\"Import and cache ``openai.OpenAI``.\"\"\"\n+    global _OPENAI_CLS_CACHE\n+    if _OPENAI_CLS_CACHE is None:\n+        from openai import OpenAI as _cls\n+        _OPENAI_CLS_CACHE = _cls\n+    return _OPENAI_CLS_CACHE\n+\n+\n+class _OpenAIProxy:\n+    \"\"\"Module-level proxy that looks like ``openai.OpenAI`` but imports lazily.\"\"\"\n+\n+    __slots__ = ()\n+\n+    def __call__(self, *args, **kwargs):\n+        return _load_openai_cls()(*args, **kwargs)\n+\n+    def __instancecheck__(self, obj):\n+        return isinstance(obj, _load_openai_cls())\n+\n+    def __repr__(self):\n+        return \"<lazy openai.OpenAI proxy>\"\n+\n+\n+OpenAI = _OpenAIProxy()\n+\n # Load .env from ~/.hermes/.env first, then project root as dev fallback.\n # User-managed env files should override stale shell exports on restart.\n 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\n             keepalive_http = self._build_keepalive_http_client(client_kwargs.get(\"base_url\", \"\"))\n             if keepalive_http is not None:\n                 client_kwargs[\"http_client\"] = keepalive_http\n+        # Uses the module-level `OpenAI` name, resolved lazily on first\n+        # access via __getattr__ below. Tests patch via `run_agent.OpenAI`.\n         client = OpenAI(**client_kwargs)\n         logger.info(\n             \"OpenAI client created (%s, shared=%s) %s\","]},{"file":"tools/web_tools.py","hunks":["@@ -47,7 +47,10 @@\n import asyncio\n from typing import List, Dict, Any, Optional\n import httpx\n-from firecrawl import Firecrawl\n+# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top —\n+# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and\n+# we only need it when the backend is actually \"firecrawl\". See\n+# _get_firecrawl_client() below for the lazy import.\n from agent.auxiliary_client import (\n     async_call_llm,\n     extract_content_or_reasoning,","@@ -236,6 +239,8 @@ def _get_firecrawl_client():\n     if _firecrawl_client is not None and _firecrawl_client_config == client_config:\n         return _firecrawl_client\n \n+    # Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used.\n+    from firecrawl import Firecrawl  # noqa: E402\n     _firecrawl_client = Firecrawl(**kwargs)\n     _firecrawl_client_config = client_config\n     return _firecrawl_client\n"]}]},{"kind":"pr","number":70500,"title":"fix(cron): allow Python scripts to use an external interpreter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#125392","number":125392},{"kind":"pr","value":"#125392","number":125392}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":125392,"title":"feat(cron): per-job Python interpreter for cron scripts (salvage #70500)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}