mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Auxiliary clients now inject a keepalive httpx transport with explicit HTTPS_PROXY/NO_PROXY resolution, matching the main agent. This avoids macOS system proxy settings (which omit the ExceptionsList) breaking vision and other auxiliary calls to internal provider endpoints.
208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
"""Process-level bootstrap helpers for ``run_agent``.
|
|
|
|
Three concerns, all tied to ``AIAgent`` boot-time / runtime IO setup:
|
|
|
|
1. **Lazy OpenAI SDK import** — ``_load_openai_cls`` + ``_OpenAIProxy``
|
|
defer the 240ms-ish ``from openai import OpenAI`` cost until first use,
|
|
while preserving ``isinstance(client, OpenAI)`` checks and
|
|
``patch("run_agent.OpenAI", ...)`` test patterns.
|
|
|
|
2. **Crash-resistant stdio** — ``_SafeWriter`` wraps stdout/stderr so
|
|
``OSError: Input/output error`` from broken pipes (systemd, Docker,
|
|
thread teardown races) cannot crash the agent. ``_install_safe_stdio``
|
|
applies the wrapper.
|
|
|
|
3. **HTTP proxy resolution** — ``_get_proxy_from_env`` reads
|
|
``HTTPS_PROXY`` / ``HTTP_PROXY`` / ``ALL_PROXY``;
|
|
``_get_proxy_for_base_url`` respects ``NO_PROXY`` for the given base URL.
|
|
|
|
``run_agent`` re-exports every name so existing
|
|
``from run_agent import _get_proxy_from_env`` imports keep working
|
|
unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
from typing import Any, Optional
|
|
|
|
from utils import base_url_hostname, normalize_proxy_url
|
|
|
|
|
|
# Cached at module level so we only pay the OpenAI SDK import cost once
|
|
# per process (after the first lazy load).
|
|
_OPENAI_CLS_CACHE = 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>"
|
|
|
|
|
|
class _SafeWriter:
|
|
"""Transparent stdio wrapper that catches OSError/ValueError from broken pipes.
|
|
|
|
When hermes-agent runs as a systemd service, Docker container, or headless
|
|
daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer
|
|
exhaustion, socket reset). Any print() call then raises
|
|
``OSError: [Errno 5] Input/output error``, which can crash agent setup or
|
|
run_conversation() — especially via double-fault when an except handler
|
|
also tries to print.
|
|
|
|
Additionally, when subagents run in ThreadPoolExecutor threads, the shared
|
|
stdout handle can close between thread teardown and cleanup, raising
|
|
``ValueError: I/O operation on closed file`` instead of OSError.
|
|
|
|
This wrapper delegates all writes to the underlying stream and silently
|
|
catches both OSError and ValueError. It is transparent when the wrapped
|
|
stream is healthy.
|
|
"""
|
|
|
|
__slots__ = ("_inner",)
|
|
|
|
def __init__(self, inner):
|
|
object.__setattr__(self, "_inner", inner)
|
|
|
|
def write(self, data):
|
|
try:
|
|
return self._inner.write(data)
|
|
except (OSError, ValueError):
|
|
return len(data) if isinstance(data, str) else 0
|
|
|
|
def flush(self):
|
|
try:
|
|
self._inner.flush()
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
def fileno(self):
|
|
return self._inner.fileno()
|
|
|
|
def isatty(self):
|
|
try:
|
|
return self._inner.isatty()
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
def _get_proxy_from_env() -> Optional[str]:
|
|
"""Read proxy URL from environment variables.
|
|
|
|
Checks HTTPS_PROXY, HTTP_PROXY, ALL_PROXY (and lowercase variants) in order.
|
|
Returns the first valid proxy URL found, or None if no proxy is configured.
|
|
"""
|
|
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
|
"https_proxy", "http_proxy", "all_proxy"):
|
|
value = os.environ.get(key, "").strip()
|
|
if value:
|
|
return normalize_proxy_url(value)
|
|
return None
|
|
|
|
|
|
def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
|
|
"""Return an env-configured proxy unless NO_PROXY excludes this base URL."""
|
|
proxy = _get_proxy_from_env()
|
|
if not proxy or not base_url:
|
|
return proxy
|
|
|
|
host = base_url_hostname(base_url)
|
|
if not host:
|
|
return proxy
|
|
|
|
try:
|
|
if urllib.request.proxy_bypass_environment(host):
|
|
return None
|
|
except Exception:
|
|
pass
|
|
|
|
return proxy
|
|
|
|
|
|
def build_keepalive_http_client(
|
|
base_url: str = "",
|
|
*,
|
|
async_mode: bool = False,
|
|
) -> Optional[Any]:
|
|
"""Build an httpx client for OpenAI SDK calls with env-only proxy policy.
|
|
|
|
Uses explicit ``HTTPS_PROXY`` / ``NO_PROXY`` env vars via
|
|
``_get_proxy_for_base_url``. A custom transport disables httpx's default
|
|
``trust_env`` path, so macOS system proxy settings from
|
|
``urllib.request.getproxies()`` (which omit the ExceptionsList) are not
|
|
applied. Mirrors ``AIAgent._build_keepalive_http_client``.
|
|
"""
|
|
try:
|
|
import httpx
|
|
import socket
|
|
|
|
if "api.githubcopilot.com" in str(base_url or "").lower():
|
|
client_cls = httpx.AsyncClient if async_mode else httpx.Client
|
|
return client_cls()
|
|
|
|
sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
|
|
if hasattr(socket, "TCP_KEEPIDLE"):
|
|
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30))
|
|
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10))
|
|
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3))
|
|
elif hasattr(socket, "TCP_KEEPALIVE"):
|
|
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30))
|
|
|
|
proxy = _get_proxy_for_base_url(base_url)
|
|
transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
|
|
client_cls = httpx.AsyncClient if async_mode else httpx.Client
|
|
return client_cls(
|
|
transport=transport_cls(socket_options=sock_opts),
|
|
proxy=proxy,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _install_safe_stdio() -> None:
|
|
"""Wrap stdout/stderr so best-effort console output cannot crash the agent."""
|
|
for stream_name in ("stdout", "stderr"):
|
|
stream = getattr(sys, stream_name, None)
|
|
if stream is not None and not isinstance(stream, _SafeWriter):
|
|
setattr(sys, stream_name, _SafeWriter(stream))
|
|
|
|
|
|
# Module-level proxy instance — drops in for ``openai.OpenAI``. Imported as
|
|
# ``from agent.process_bootstrap import OpenAI`` (or re-exported via
|
|
# ``run_agent`` for legacy tests).
|
|
OpenAI = _OpenAIProxy()
|
|
|
|
|
|
__all__ = [
|
|
"OpenAI",
|
|
"_OpenAIProxy",
|
|
"_load_openai_cls",
|
|
"_SafeWriter",
|
|
"_install_safe_stdio",
|
|
"_get_proxy_from_env",
|
|
"_get_proxy_for_base_url",
|
|
"build_keepalive_http_client",
|
|
]
|