fix(imports): keep `import gateway` free of httpx via lazy AuxiliaryExplicitCancellation import

`import gateway` reaches agent/conversation_compression.py through
gateway/session.py -> agent/turn_context.py, and a module-level
`from agent.auxiliary_client import AuxiliaryExplicitCancellation`
there dragged in agent.credential_pool -> hermes_cli.auth -> httpx at
import time. Minimal consumers that import the gateway package with only
the lightweight wire deps (websockets/aiohttp/pyyaml/requests) — e.g. the
gateway-gateway cross-repo live E2E suite — now crash with
ModuleNotFoundError: No module named 'httpx' before running anything.

Move the import to call time inside compress_context(), matching the
existing lazy-import pattern for aux_progress_hook /
aux_interrupt_protection in the same function. Both uses of the exception
class (the raise and the except) are inside compress_context's dynamic
extent, so behavior is unchanged.

Add tests/gateway/test_gateway_import_hygiene.py: a fresh-interpreter
probe that blocks httpx/openai/anthropic on the meta path and imports
gateway.relay.ws_transport, so an eager heavyweight import on the
gateway path fails CI instead of breaking downstream repos.

Verified: probe fails on current main, passes with this change; also
reproduced in a real minimal venv (pip install websockets aiohttp pyyaml
requests) where `from gateway.relay.ws_transport import
WebSocketRelayTransport` now succeeds with httpx absent.
This commit is contained in:
Ben Barclay 2026-08-03 14:26:58 -06:00
parent a991dfc25d
commit 244b6fdb55
2 changed files with 73 additions and 1 deletions

View File

@ -66,7 +66,6 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from agent.auxiliary_client import AuxiliaryExplicitCancellation
from agent.context_engine import (
automatic_compaction_status_message,
sanitize_memory_context,
@ -2166,6 +2165,13 @@ def compress_context(
prompt the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
# Imported at call time so that ``import gateway`` (which reaches this
# module via agent.turn_context) stays free of the auxiliary-client →
# credential-pool → hermes_cli.auth → httpx chain. Minimal consumers
# (cross-repo E2E venvs, tooling) import the gateway package without the
# full agent dependency set.
from agent.auxiliary_client import AuxiliaryExplicitCancellation
_compressor_attempt_snapshot = _snapshot_compressor_attempt_state(
agent.context_compressor
)

View File

@ -0,0 +1,66 @@
"""Import-hygiene guard: ``import gateway`` must not require httpx.
The gateway package is imported by minimal consumers that install only the
lightweight wire deps (websockets/aiohttp/pyyaml/requests) notably the
gateway-gateway cross-repo live E2E suite, which drives
``gateway.relay.ws_transport`` from a venv without the full agent dependency
set. Any module reachable from ``import gateway`` that eagerly imports
``hermes_cli.auth`` (and therefore httpx) breaks every one of those consumers
at import time.
Regression: agent/conversation_compression.py grew a module-level
``from agent.auxiliary_client import AuxiliaryExplicitCancellation`` which
pulled in agent.credential_pool hermes_cli.auth httpx during
``import gateway``.
"""
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# Runs in a fresh interpreter so previously-imported modules in the test
# process can't mask an eager import. A meta-path hook makes httpx (and the
# heavyweight SDK clients) unimportable, mimicking the minimal E2E venv.
_PROBE = """
import sys
BLOCKED = {"httpx", "openai", "anthropic"}
class _Blocker:
def find_module(self, fullname, path=None):
if fullname.split(".")[0] in BLOCKED:
return self
return None
def load_module(self, fullname):
raise ImportError(f"blocked by import-hygiene test: {fullname}")
sys.meta_path.insert(0, _Blocker())
from gateway.relay.ws_transport import WebSocketRelayTransport # noqa: F401
import gateway # noqa: F401
leaked = sorted(m for m in sys.modules if m.split(".")[0] in BLOCKED)
if leaked:
raise SystemExit(f"blocked modules leaked into sys.modules: {leaked}")
print("import-hygiene-ok")
"""
def test_import_gateway_does_not_require_httpx():
result = subprocess.run(
[sys.executable, "-c", _PROBE],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, (
"`import gateway` dragged in a blocked heavyweight dependency "
"(httpx/openai/anthropic). Minimal consumers (cross-repo E2E venvs) "
"import the gateway package without the full agent deps — keep those "
f"imports lazy.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "import-hygiene-ok" in result.stdout