diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bfb534a9934b..a0a08649762e 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -155,6 +155,9 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any: return True +_WARNED_KEEPALIVE_IMPORT_SKEW = False + + def _openai_http_client_kwargs( base_url: Optional[str], *, @@ -169,15 +172,26 @@ def _openai_http_client_kwargs( verify=_resolve_aux_verify(base_url), ) except (ImportError, AttributeError): - # Fallback for older hermes-agent versions without build_keepalive_http_client. - # This can happen when Desktop users run with stale code (#64333). - # Without the keepalive client, auxiliary calls use the OpenAI SDK's default - # httpx client, which respects macOS system proxy (less predictable for env-only - # proxy use) and has no pool-level keepalive expiry (connections may live longer). - # This is a graceful degradation — functionality still works, just with slightly - # different proxy/keepalive behavior. + # Version-skewed installs (#64333): a process whose sys.path resolves + # an older agent/process_bootstrap.py without this helper — seen when + # the Desktop app's bundled runtime lags a git-installed source tree + # that newer callers (cron scheduler) were written against. Every cron + # job died on this ImportError before any agent logic ran. Degrade + # gracefully to the OpenAI SDK's default httpx client (respects macOS + # system proxy, no pool-level keepalive expiry) instead of failing the + # whole job, and say so once — silent version skew is how this bug + # went unnoticed until jobs were already dead on arrival. + global _WARNED_KEEPALIVE_IMPORT_SKEW + if not _WARNED_KEEPALIVE_IMPORT_SKEW: + _WARNED_KEEPALIVE_IMPORT_SKEW = True + logger.warning( + "agent.process_bootstrap.build_keepalive_http_client is " + "unavailable — mixed/stale install detected (#64333). Falling " + "back to the SDK default HTTP client. Run `hermes update` (or " + "reinstall the Desktop app) to resync the runtime." + ) client = None - + if client is None: return {} return {"http_client": client} diff --git a/tests/agent/test_auxiliary_client_bootstrap_skew.py b/tests/agent/test_auxiliary_client_bootstrap_skew.py new file mode 100644 index 000000000000..d66b436c1d70 --- /dev/null +++ b/tests/agent/test_auxiliary_client_bootstrap_skew.py @@ -0,0 +1,47 @@ +"""Regression for #64333 — auxiliary client must survive a version-skewed +agent.process_bootstrap that lacks build_keepalive_http_client. + +Desktop installs can run a runtime whose agent/process_bootstrap.py predates +the helper while newer callers (cron scheduler → auxiliary_client) expect it. +Before the fix the module-level import made every cron job die with +ImportError before any agent logic ran. +""" + +import builtins +import logging + +import agent.auxiliary_client as aux + + +def test_missing_bootstrap_helper_degrades_instead_of_raising(monkeypatch, caplog): + """ImportError from process_bootstrap → empty kwargs + one-time warning.""" + real_import = builtins.__import__ + + def _fail_bootstrap(name, *args, **kwargs): + if name == "agent.process_bootstrap": + raise ImportError( + "cannot import name 'build_keepalive_http_client' " + "from 'agent.process_bootstrap'" + ) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _fail_bootstrap) + monkeypatch.setattr(aux, "_WARNED_KEEPALIVE_IMPORT_SKEW", False) + + with caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"): + result = aux._openai_http_client_kwargs("https://api.example.com/v1") + again = aux._openai_http_client_kwargs("https://api.example.com/v1") + + assert result == {} + assert again == {} + skew_warnings = [ + r for r in caplog.records if "mixed/stale install" in r.getMessage() + ] + assert len(skew_warnings) == 1 # warned once, not per call + + +def test_healthy_bootstrap_still_injects_keepalive_client(): + """With the helper present, the keepalive http_client is injected.""" + result = aux._openai_http_client_kwargs("https://api.example.com/v1") + assert "http_client" in result + assert result["http_client"] is not None