diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9589edad..5d147e43 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1204,6 +1204,53 @@ _client_cache: Dict[tuple, tuple] = {} _client_cache_lock = threading.Lock() +def _force_close_async_httpx(client: Any) -> None: + """Mark the httpx AsyncClient inside an AsyncOpenAI client as closed. + + This prevents ``AsyncHttpxClientWrapper.__del__`` from scheduling + ``aclose()`` on a (potentially closed) event loop, which causes + ``RuntimeError: Event loop is closed`` → prompt_toolkit's + "Press ENTER to continue..." handler. + + We intentionally do NOT run the full async close path — the + connections will be dropped by the OS when the process exits. + """ + try: + from httpx._client import ClientState + inner = getattr(client, "_client", None) + if inner is not None and not getattr(inner, "is_closed", True): + inner._state = ClientState.CLOSED + except Exception: + pass + + +def shutdown_cached_clients() -> None: + """Close all cached clients (sync and async) to prevent event-loop errors. + + Call this during CLI shutdown, *before* the event loop is closed, to + avoid ``AsyncHttpxClientWrapper.__del__`` raising on a dead loop. + """ + import inspect + + with _client_cache_lock: + for key, entry in list(_client_cache.items()): + client = entry[0] + if client is None: + continue + # Mark any async httpx transport as closed first (prevents __del__ + # from scheduling aclose() on a dead event loop). + _force_close_async_httpx(client) + # Sync clients: close the httpx connection pool cleanly. + # Async clients: skip — we already neutered __del__ above. + try: + close_fn = getattr(client, "close", None) + if close_fn and not inspect.iscoroutinefunction(close_fn): + close_fn() + except Exception: + pass + _client_cache.clear() + + def _get_cached_client( provider: str, model: str = None, @@ -1222,6 +1269,7 @@ def _get_cached_client( # "Event loop is closed" when httpx tries to clean up its # transport. Discard the stale client and create a fresh one. if cached_loop is not None and cached_loop.is_closed(): + _force_close_async_httpx(cached_client) del _client_cache[cache_key] else: return cached_client, model or cached_default diff --git a/cli.py b/cli.py index 9ca51023..43ae081e 100644 --- a/cli.py +++ b/cli.py @@ -498,6 +498,14 @@ def _run_cleanup(): shutdown_mcp_servers() except Exception: pass + # Close cached auxiliary LLM clients (sync + async) so that + # AsyncHttpxClientWrapper.__del__ doesn't fire on a closed event loop + # and trigger prompt_toolkit's "Press ENTER to continue..." handler. + try: + from agent.auxiliary_client import shutdown_cached_clients + shutdown_cached_clients() + except Exception: + pass # =============================================================================