NousResearch/hermes-agent

Offload blocking I/O calls off async event loops

Cluster 44560 · apps/desktop. Data as of

2 open items. Fix merged in #58183 on 4 Jul 2026. Check it covers your case.

The short answer

0open issues
2open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #58183. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #58183. That is separate from the fix event.
Still open
0 issues and 2 PRs.

These issues and PRs address synchronous HTTP calls, model discovery, and security checks running on async event loops, causing freezes and timeouts in Discord, Telegram, WebSocket, and desktop contexts. Fixes involve threading, caching, TTLs, and deferring work to executors.

Open issues0

No open issues.

Open PRs2

  • PR#56922fix(desktop): fast-path model options and validate provider matches
  • PR#59852fix(tui): stop kill() from letting a stale respawn race a dying gateway child

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #58183 fix(desktop): avoid probing custom providers on model picker open (salvage #49713)

    merged to main

    files: hermes_cli/inventory.py, hermes_cli/model_switch.py, hermes_cli/web_server.py, tests/hermes_cli/test_inventory.py, tests/hermes_cli/test_model_switch_custom_providers.py, tests/test_tui_gateway_server.py, tui_gateway/server.py

    Show the diff
    • hermes_cli/inventory.py
      @@ -118,6 +118,7 @@ def build_models_payload(
           capabilities: bool = False,
           force_fresh_nous_tier: bool = False,
           refresh: bool = False,
      +    probe_custom_providers: bool = True,
           max_models: int | None = None,
       ) -> dict:
           """Build the ``{providers, model, provider}`` shape every consumer
      @@ -149,6 +150,11 @@ def build_models_payload(
             re-fetches its live catalog. Set only for an explicit user-triggered
             "refresh models" action; normal picker opens leave it false to stay
             snappy on the 1h cache.
      +    - ``probe_custom_providers``: allow saved custom/provider endpoints to
      +      run live ``/models`` discovery while building the payload. GUI picker
      +      opens should leave this false unless the user explicitly refreshes; the
      +      row can still render its configured model immediately, and slow/offline
      +      local endpoints no longer block the dialog.
           """
           from hermes_cli.model_switch import list_authenticated_providers
       
      @@ -161,6 +167,7 @@ def build_models_payload(
               force_fresh_nous_tier=force_fresh_nous_tier,
               max_models=max_models,
               refresh=refresh,
      +        probe_custom_providers=probe_custom_providers,
           )
       
           moa_row = _moa_provider_row(ctx.current_provider)
    • hermes_cli/model_switch.py
      @@ -1426,6 +1426,7 @@ def list_authenticated_providers(
           max_models: int | None = None,
           current_model: str = "",
           refresh: bool = False,
      +    probe_custom_providers: bool = True,
       ) -> List[dict]:
           """Detect which providers have credentials and list their curated models.
       
      @@ -1452,6 +1453,11 @@ def list_authenticated_providers(
           live catalog. Use for an explicit user-triggered "refresh models" action
           (e.g. the desktop picker's refresh control); leave false for normal picker
           opens so they stay snappy on the 1h cache.
      +
      +    ``probe_custom_providers`` controls live ``/models`` discovery for saved
      +    custom OpenAI-compatible endpoints. Keep the default true for CLI parity;
      +    GUI picker opens can pass false to show configured models immediately
      +    without waiting on offline local endpoints.
           """
           import os
           from agent.models_dev import (
      @@ -1994,7 +2000,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                   if isinstance(discover, str):
                       discover = discover.lower() not in {"false", "no", "0"}
                   has_explicit_models = bool(models_list)
      -            should_probe = bool(api_url) and discover and (
      +            should_probe = probe_custom_providers and bool(api_url) and discover and (
                       bool(api_key) or not has_explicit_models
                   )
                   if should_probe:
      @@ -2255,7 +2261,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                   #   full aggregator catalog via /models but only serve a subset
                   #   (parity with section 3's user ``providers:`` behaviour).
                   should_probe = (
      -                bool(api_url)
      +                probe_custom_providers
      +                and bool(api_url)
                       and (bool(api_key) or not grp["models"])
                       and grp.get("discover_models", True)
                   )
    • hermes_cli/web_server.py
      @@ -4225,6 +4225,7 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False):
                       pricing=True,
                       capabilities=True,
                       refresh=bool(refresh),
      +                probe_custom_providers=bool(refresh),
                   )
           except HTTPException:
               raise
    • tests/hermes_cli/test_inventory.py
      @@ -219,6 +219,19 @@ def test_build_models_payload_can_force_fresh_nous_tier():
           assert mock_list.call_args.kwargs["force_fresh_nous_tier"] is True
       
       
      +def test_build_models_payload_can_skip_custom_provider_probes():
      +    ctx = _empty_ctx()
      +    rows = []
      +    with patch(
      +        "hermes_cli.model_switch.list_authenticated_providers",
      +        return_value=rows,
      +    ) as mock_list:
      +        build_models_payload(ctx, probe_custom_providers=False)
      +
      +    mock_list.assert_called_once()
      +    assert mock_list.call_args.kwargs["probe_custom_providers"] is False
      +
      +
       def test_list_authenticated_providers_force_fresh_is_keyword_only():
           """``force_fresh_nous_tier`` must be keyword-only on the public listing API.
       
    • tests/hermes_cli/test_model_switch_custom_providers.py
      @@ -45,6 +45,30 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
           )
       
       
      +def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkeypatch):
      +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
      +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
      +    fetch = lambda *a, **k: (_ for _ in ()).throw(AssertionError("unexpected probe"))
      +    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
      +
      +    providers = list_authenticated_providers(
      +        user_providers={},
      +        custom_providers=[
      +            {
      +                "name": "Slow Local",
      +                "base_url": "http://127.0.0.1:8080/v1",
      +                "api_key": "[redacted:secret-assignment]",
      +                "model": "local-model",
      +            }
      +        ],
      +        probe_custom_providers=False,
      +    )
      +
      +    row = next(p for p in providers if p["slug"] == "custom:slow-local")
      +    assert row["models"] == ["local-model"]
      +    assert row["total_models"] == 1
      +
      +
       def test_resolve_provider_full_finds_named_custom_provider():
           """Explicit /model --provider should resolve saved custom_providers entries."""
           resolved = resolve_provider_full(
    • tests/test_tui_gateway_server.py
      @@ -5845,6 +5845,7 @@ def test_model_options_does_not_overwrite_curated_models(monkeypatch):
           live_fetch.assert_not_called()
           # list_authenticated_providers is the single source.
           assert listing.call_count == 1
      +    assert listing.call_args.kwargs["probe_custom_providers"] is False
       
       
       def test_model_options_propagates_list_exception(monkeypatch):
      @@ -5870,6 +5871,22 @@ def test_model_options_propagates_list_exception(monkeypatch):
       # ---------------------------------------------------------------------------
       
       
      +def test_model_options_refresh_allows_custom_provider_probes(monkeypatch):
      +    monkeypatch.setattr(
      +        server,
      +        "_load_cfg",
      +        lambda: {"providers": {}, "custom_providers": []},
      +    )
      +    with patch(
      +        "hermes_cli.model_switch.list_authenticated_providers",
      +        return_value=[],
      +    ) as listing:
      +        resp = server._methods["model.options"](78, {"session_id": "", "refresh": True})
      +
      +    assert "result" in resp, resp
      +    assert listing.call_args.kwargs["probe_custom_providers"] is True
      +
      +
       class _ImmediateThread:
           """Runs the target callable synchronously so assertions can follow."""
       
    • tui_gateway/server.py
      @@ -12325,6 +12325,7 @@ def _(rid, params: dict) -> dict:
                   pricing=True,
                   capabilities=True,
                   refresh=bool(params.get("refresh")),
      +            probe_custom_providers=bool(params.get("refresh")),
               )
               return _ok(rid, payload)
           except Exception as e:

2 PRs closed without merging

From the close comments: superseded by another PR 2

  • #44577 fix(model-switch): cache custom provider model discovery to unblock WebSocket handler · superseded by another PR → #58183, #49713
  • #44598 fix(gateway): cache fetch_api_models per-provider with TTL and cap timeout to prevent model.options WS timeout · superseded by another PR → #58183, #49713
Closed issues · 1
  • Closed#44560model.options handler blocks on synchronous per-provider HTTP calls, causing WebSocket timeout
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#58183
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
4 Jul 2026

For your agent

The same facts, typed.