{"repo":"NousResearch/hermes-agent","public_id":"52217","display_label":"Reasoning model stale-stream timeout floors and fixes","summary":"These issues and PRs address reasoning models (OpenAI o1/o3, DeepSeek R1, Nemotron, Opus thinking, MiniMax M2.x, etc.) being incorrectly killed mid-think due to inheriting short chat-model stale-stream timeout defaults. Work includes adding per-model timeout floors and routing timeout errors correctly.","subsystem":"agent","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52795},"resolution":{"state":"resolved","by":"pr-52845,pr-121555"},"freshness":{"state":"verified","last_verified_at":"2026-09-25T15:58:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17018181818181818},"fix_edges":[{"issue_number":52217,"pr_number":52795,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":52217,"pr_number":52845,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":52271,"pr_number":52795,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62353,"pr_number":121555,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":69734,"pr_number":121555,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":97968,"pr_number":121555,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":52217,"title":"[Bug]: Reasoning models inherit chat-model stale-timeout defaults — broken-pipe on long-thinking models like Nemotron 3 Ultra, OpenAI o1/o3, Opus 4.x thinking","url":null,"state":"closed"},{"kind":"issue","number":62353,"title":"Bug: MiniMax M2.x (reasoning model) missing from reasoning_timeouts.py stale-stream floor — stale-killed mid-think at 180s","url":null,"state":"closed"},{"kind":"pr","number":52231,"title":"fix(agent): raise stale-stream timeout for reasoning models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"27c486e3b1b6da00d5f5dbeabffe03b7ba3bbcfa","number":0},{"kind":"pr","value":"#52217","number":52217}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52238,"title":"feat(agent): per-reasoning-model stale-timeout floor (Nemotron 3 Ultra, OpenAI o1/o3, Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52845","number":52845},{"kind":"pr","value":"#52795","number":52795}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52795,"title":"fix(agent): route reasoning-model thinking-timeouts to timeout, not phantom context_overflow (#52271)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","agent/error_classifier.py","agent/reasoning_timeouts.py","agent/thinking_timeout_guidance.py","scripts/release.py","tests/agent/test_thinking_timeout_guidance.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -3527,6 +3527,65 @@ def _perform_api_call(next_api_kwargs):\n                             force=True,\n                         )\n \n+                    # Detect thinking-timeout pattern: a known reasoning model\n+                    # hit a transport-layer error before the first content\n+                    # token arrived.  Distinct from _is_stream_drop above\n+                    # (which fires for large file-write stream drops) and\n+                    # from any classifier reason that's not a transport\n+                    # timeout.  Reuses the reasoning-model allowlist from\n+                    # agent/reasoning_timeouts.py (Fixes #52217) so the\n+                    # trigger is consistent with what the per-model\n+                    # stale-timeout floor covers.  After the classifier\n+                    # override at agent/error_classifier.py:720-738 (this\n+                    # PR), transport disconnects on reasoning models route\n+                    # to FailoverReason.timeout rather than\n+                    # context_overflow, so this branch actually fires.\n+                    # Detection and message text live in\n+                    # agent.thinking_timeout_guidance so they're\n+                    # unit-testable without driving the full retry loop.\n+                    # (Part 2 of Fixes #52310.)\n+                    from agent.thinking_timeout_guidance import (\n+                        is_thinking_timeout,\n+                    )\n+                    _is_thinking_timeout = is_thinking_timeout(\n+                        classified,\n+                        _model,\n+                        error_msg,\n+                    )\n+                    if _is_thinking_timeout:\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}   💡 The model's thinking \"\n+                            f\"phase exceeded the upstream proxy's idle \"\n+                            f\"timeout before the first content token \"\n+                            f\"arrived. This is a known issue with \"\n+                            f\"reasoning models behind cloud gateways \"\n+                            f\"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).\",\n+                            force=True,\n+                        )\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}      Workarounds in priority order:\",\n+                            force=True,\n+                        )\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}      1. Set \"\n+                            f\"`providers.{_provider}.models.{_model}.stale_timeout_seconds: 900` \"\n+                            f\"in `~/.hermes/config.yaml` to extend the per-call \"\n+                            f\"timeout. (Hermes's built-in floor is 600s for \"\n+                            f\"known reasoning models — if you still see this \"\n+                            f\"after raising, the upstream cap is even shorter.)\",\n+                            force=True,\n+                        )\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}      2. Lower `reasoning_budget` or set \"\n+                            f\"`reasoning_effort: medium` on this model if the provider supports it.\",\n+                            force=True,\n+                        )\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}      3. Use a smaller / faster reasoning \"\n+                            f\"model if the task doesn't require deep thinking.\",\n+                            force=True,\n+                        )\n+\n                     logger.error(\n                         \"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s\",\n                         agent.log_prefix, max_retries, _final_summary,","@@ -3543,7 +3602,22 @@ def _perform_api_call(next_api_kwargs):\n                             _final_response += f\"\\n\\n{_billing_guidance}\"\n                     else:\n                         _final_response = f\"API call failed after {max_retries} retries: {_final_summary}\"\n-                    if _is_stream_drop:\n+                    if _is_thinking_timeout:\n+                        # Thinking-timeout guidance overrides the generic\n+                        # stream-drop guidance — the latter is wrong for\n+                        # this case (it suggests splitting large file\n+                        # writes, which isn't what happened).  See the\n+                        # reasoning-model override at\n+                        # agent/error_classifier.py:720-738 and the\n+                        # detection block above for context.\n+                        from agent.thinking_timeout_guidance import (\n+                            build_thinking_timeout_guidance,\n+                        )\n+                        _final_response += build_thinking_timeout_guidance(\n+                            provider=_provider,\n+                            model=_model,\n+                        )\n+                    elif _is_stream_drop:\n                         _final_response += (\n                             \"\\n\\nThe provider's stream connection keeps \"\n                             \"dropping — this often happens when generating \""]},{"file":"agent/error_classifier.py","hunks":["@@ -717,6 +717,26 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:\n \n     is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)\n     if is_disconnect and not status_code:\n+        # Reasoning-model override: a transport disconnect on a reasoning\n+        # model is much more likely the upstream proxy idle-killing a\n+        # long thinking stream than a true context overflow — even on\n+        # large sessions.  The default disconnect+large-session routing\n+        # below would otherwise send the user into the compression\n+        # branch (should_compress=True) and silently delete\n+        # conversation history on a phantom context-length error.\n+        # Reasoning models have multi-minute thinking phases that\n+        # routinely exceed the cloud gateway's idle window (NVIDIA\n+        # NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;\n+        # OpenAI worker / Anthropic stream-idle similar).  The\n+        # per-reasoning-model stale-timeout floor in\n+        # agent/reasoning_timeouts.py raises the stale-detector\n+        # threshold to tolerate long thinking, so a true\n+        # transport-layer failure here is recoverable via the retry\n+        # path — not via context compression.  Reclassify as timeout.\n+        # (Part 1 of Fixes #52310.)\n+        from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+        if get_reasoning_stale_timeout_floor(model) is not None:\n+            return _result(FailoverReason.timeout, retryable=True)\n         # Absolute token/message-count thresholds are only a proxy for smaller\n         # context windows.  Large-context sessions can have hundreds of\n         # messages while still being far below their actual token budget."]},{"file":"agent/reasoning_timeouts.py","hunks":["@@ -0,0 +1,216 @@\n+\"\"\"Per-reasoning-model stale-timeout floor for known reasoning models.\n+\n+Reasoning models (those that emit extended thinking blocks before their\n+first content token) routinely exceed Hermes's default chat-model\n+stale detectors:\n+\n+* Stream stale detector:   ``HERMES_STREAM_STALE_TIMEOUT``     default 180s\n+                           ``agent/chat_completion_helpers.py:2544``\n+* Non-stream stale detector: ``HERMES_API_CALL_STALE_TIMEOUT``  default 90s\n+                           ``run_agent.py:1140``\n+\n+For NVIDIA Nemotron 3 Ultra on the hosted NIM gateway the empirical\n+upstream idle kill is ~120s (first-party reproduction at\n+NVIDIA/NemoClaw#4846 — TTFB ~31s, stream dies at 120s). The same\n+failure mode exists on OpenAI o1/o3, Anthropic Opus 4.x thinking,\n+DeepSeek R1, Qwen QwQ, xAI Grok reasoning — every cloud reasoning\n+model hits upstream-proxies / load-balancers with idle timeouts\n+shorter than the model's thinking phase. Result: the stale detector\n+kills the connection mid-think, surfacing as\n+``BrokenPipeError``/``RemoteProtocolError`` on the next read.\n+\n+This module provides a floor that the existing stale-detector scaling\n+blocks consult via :func:`get_reasoning_stale_timeout_floor` and\n+apply as ``max(default, floor)``. It is a FLOOR:\n+\n+* Never overrides explicit user config (``providers.<id>.models.<model>.stale_timeout_seconds``\n+  or ``request_timeout_seconds`` already wins — this code never runs\n+  in that branch).\n+* Never lowers an existing threshold.\n+* Has zero effect on non-reasoning models — they are not in the\n+  allowlist and the resolver returns ``None``.\n+\n+Matching uses start-anchored regex on the slug-only component of\n+the model name (after stripping any aggregator prefix like\n+``openai/``, ``x-ai/``, ``anthropic/``).  The right-anchor matches\n+end-of-string or a ``-``/``.``/``_`` slug separator, so ``qwen3-235b``\n+matches the ``qwen3`` family entry (a future model slug would be\n+``qwen3-235b-instruct`` and would also match) but ``some-other-qwen3``\n+does NOT match ``qwen3`` (the ``-qwen3`` is not at start of slug).\n+\n+The ``o1`` case is the most delicate: a model named\n+``llama-4-70b-o1-preview`` is a hypothetical community derivative that\n+should NOT trigger the reasoning-model floor for the user (the user\n+chose a non-OpenAI model, not a reasoning model).  The start-of-slug\n+anchor naturally excludes this — the matched ``o1-preview`` is at\n+position 11 of the slug, not at position 0.  The previous substring-\n+with-trailing-hyphen design would have over-matched here, which is\n+why start-of-slug anchoring is the right shape.\n+\n+Fixes #52217.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+from typing import Optional\n+\n+\n+# (slug, floor_seconds).  Each slug is matched as a discrete\n+# word-boundary component via the wrapper regex in ``_match_any``\n+# below.  Order is irrelevant — the first regex match wins.\n+_REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (\n+    # NVIDIA Nemotron — reasoning models behind hosted NIM with\n+    # documented 60-180s upstream idle kill (NVIDIA/NemoClaw#4846:\n+    # 120s measured).\n+    (\"nemotron-3-ultra\", 600),\n+    (\"nemotron-3-super\", 600),\n+    (\"nemotron-3-nano\",  300),\n+    # DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.\n+    (\"deepseek-r1\", 600),\n+    (\"deepseek-reasoner\", 600),\n+    # Qwen — QwQ reasoning + Qwen3 thinking variants.  QwQ-32B\n+    # preview is the stable slug; ``qwen3`` covers the family of\n+    # thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)\n+    # without over-matching every Qwen3 instruct variant — the\n+    # right-anchor requires the slug to be at the start of the\n+    # remaining model name, so ``qwen3-235b-instruct`` (instruct is\n+    # NOT a thinking variant) would still match.  Acceptable\n+    # trade-off: instruct variants of qwen3 get the 180s floor\n+    # even though they don't reason.  The cost is a slightly longer\n+    # wait on a hung provider; the alternative (matching only\n+    # ``qwen3-.*-thinking``) breaks the moment NVIDIA or Alibaba\n+    # ships a slightly different naming shape.\n+    (\"qwq-32b\", 300),\n+    (\"qwen3\", 180),\n+    # OpenAI o-series — known multi-minute TTFB.  Each variant\n+    # enumerated explicitly so bare ``o1`` doesn't over-match\n+    # ``olmo-1`` or hypothetical future community derivatives.\n+    (\"o1\", 600),\n+    (\"o1-mini\", 600),\n+    (\"o1-pro\", 600),\n+    (\"o1-preview\", 600),\n+    (\"o3\", 600),\n+    (\"o3-pro\", 600),\n+    (\"o3-mini\", 300),\n+    (\"o4-mini\", 300),\n+    # Anthropic Claude 4.x thinking variants.  Anchored at\n+    # ``claude-opus-4`` so non-thinking Claude 3.x or future\n+    # non-reasoning Claude variants don't match.\n+    (\"claude-opus-4\", 240),\n+    (\"claude-sonnet-4.5\", 180),\n+    (\"claude-sonnet-4.6\", 180),\n+    # xAI Grok reasoning variants.  Explicit reasoning-only keys\n+    # plus one for the ``non-reasoning`` variant so users picking\n+    # the fast variant don't get the 300s floor.  Bare ``grok-3``,\n+    # ``grok-4`` etc. don't match — only the explicit reasoning /\n+    # non-reasoning pairs.\n+    (\"grok-4-fast-reasoning\", 300),\n+    (\"grok-4.20-reasoning\", 300),\n+    (\"grok-4-fast-non-reasoning\", 180),\n+)\n+\n+\n+# Pre-compile each pattern.  Wrapper = start-of-slug + slug + end-or-\n+# separator, where ``start-of-slug`` means start-of-string OR\n+# immediately after the last ``/`` (aggregator separator) and\n+# ``end-or-separator`` means end-of-string OR a ``-``/``.``/``_``.\n+#\n+# Why start-of-slug and not start-of-string: aggregator prefixes\n+# like ``openai/`` should not affect matching — the slug identity is\n+# the part after the last ``/``.  Stripping the aggregator prefix in\n+# :func:`get_reasoning_stale_timeout_floor` before regex matching\n+# gives the wrapper a clean start-of-string anchor.\n+#\n+# Why end-or-separator on the right: ``openai/o3-mini`` must match\n+# the ``o3-mini`` slug (the right anchor is end-of-string).  And\n+# ``openai/o3-mini-2025-01-31`` must also match ``o3-mini`` (the right\n+# anchor is the ``-`` separator).  But ``openai/o3-mini-fork`` should\n+# NOT match ``o3-mini`` if we wanted to exclude forks — though the\n+# pattern ``o3-mini-fork`` would be matched as a derivative anyway,\n+# so we accept that community forks inheriting the same prefix are\n+# treated as reasoning models (a reasonable default — the upstream\n+# gateway timing is the same).\n+_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}\n+\n+\n+def _get_pattern(slug: str) -> re.Pattern[str]:\n+    compiled = _PATTERN_CACHE.get(slug)\n+    if compiled is None:\n+        compiled = re.compile(\n+            r\"^\"\n+            + re.escape(slug)\n+            + r\"(?:$|[\\-._])\"\n+        )\n+        _PATTERN_CACHE[slug] = compiled\n+    return compiled\n+\n+\n+def _match_any(model_lower: str) -> Optional[float]:\n+    \"\"\"Return the floor for the first matching slug, else None.\n+\n+    Each table entry is matched as a start-of-slug prefix with the\n+    slug-separator-or-end-of-string right-anchor.  Table iteration\n+    order is irrelevant: longest slug wins (so ``o3-mini`` beats\n+    ``o3`` on a model like ``openai/o3-mini``).\n+    \"\"\"\n+    # Sort by slug length descending so longer / more-specific slugs\n+    # win on shared prefixes (o3-mini beats o3).\n+    sorted_floors = sorted(\n+        _REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])\n+    )\n+    for slug, floor in sorted_floors:\n+        if _get_pattern(slug).search(model_lower):\n+            return float(floor)\n+    return None\n+\n+\n+def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:\n+    \"\"\"Return the stale-timeout floor (seconds) for a known reasoning model.\n+\n+    Returns ``None`` when the model is not in the allowlist or the\n+    argument is empty / not a string.  Matching uses\n+    word-boundary-anchored regex on the lowercased model name, so\n+    ``openai/o3-mini`` matches the ``o3-mini`` slug but\n+    ``olmo-1`` does NOT match ``o1`` (the ``o1`` substring is not\n+    at a word boundary inside ``olmo-1``).\n+\n+    Aggregator prefixes (``openai/``, ``x-ai/``, ``anthropic/`` etc.)\n+    are preserved through matching — the ``/`` is itself a word\n+    boundary, so ``openai/o3-mini`` matches ``o3-mini`` because the\n+    ``/`` before ``o3-mini`` satisfies the left-anchor alternation.\n+\n+    This is a FLOOR — callers must apply it as ``max(default, floor)``\n+    and only when no explicit user-configured per-model\n+    ``stale_timeout_seconds`` exists.\n+\n+    >>> get_reasoning_stale_timeout_floor(\"nvidia/nemotron-3-ultra-550b-a55b\")\n+    600.0\n+    >>> get_reasoning_stale_timeout_floor(\"openai/o3-mini\")\n+    300.0\n+    >>> get_reasoning_stale_timeout_floor(\"deepseek/deepseek-r1\")\n+    600.0\n+    >>> get_reasoning_stale_timeout_floor(\"qwen/qwen3-235b-a22b-thinking\")\n+    180.0\n+    >>> get_reasoning_stale_timeout_floor(\"x-ai/grok-4-fast-reasoning\")\n+    300.0\n+    >>> get_reasoning_stale_timeout_floor(\"anthropic/claude-opus-4-6\")\n+    240.0\n+    >>> get_reasoning_stale_timeout_floor(\"gpt-4o\") is None\n+    True\n+    >>> get_reasoning_stale_timeout_floor(\"olmo-1\") is None\n+    True\n+    >>> get_reasoning_stale_timeout_floor(None) is None\n+    True\n+    \"\"\"\n+    if not model or not isinstance(model, str):\n+        return None\n+    name = model.strip().lower()\n+    if not name:\n+        return None\n+    # Strip aggregator prefix (everything before and including the\n+    # last ``/``).  The wrapper regex anchors at start-of-string, so\n+    # the slug identity is the bare model name.\n+    if \"/\" in name:\n+        name = name.rsplit(\"/\", 1)[1]\n+    return _match_any(name)"]},{"file":"agent/thinking_timeout_guidance.py","hunks":["@@ -0,0 +1,136 @@\n+\"\"\"Thinking-timeout detection and user-facing guidance for reasoning models.\n+\n+When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,\n+Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)\n+hits a transport-layer error before the first content token arrives, the\n+upstream proxy has almost certainly idle-killed a long thinking stream —\n+not a true context overflow or a configuration error.  The user needs\n+distinct guidance for this case:\n+\n+    \"The model's thinking phase exceeded the upstream proxy's idle\n+     timeout before the first content token arrived.  This is a known\n+     issue with reasoning models behind cloud gateways (NVIDIA NIM,\n+     OpenAI, Anthropic, DeepSeek).  Workarounds in priority order:\n+     1. Set `providers.<provider>.models.<model>.stale_timeout_seconds: 900`\n+        in `~/.hermes/config.yaml` to extend the per-call timeout...\n+     2. Lower `reasoning_budget` or set `reasoning_effort: medium`...\n+     3. Use a smaller / faster reasoning model...\"\n+\n+The existing `_is_stream_drop` guidance at\n+``agent/conversation_loop.py:3464-3486`` fires for large-file-write\n+stream drops (\"try execute_code with Python's open() for large files\")\n+which is the WRONG advice for the thinking-timeout case.  This module\n+provides the detection and the message as standalone helpers so the\n+detection logic is unit-testable without driving the full retry loop,\n+and the message text can be regression-tested for spelling and accuracy.\n+\n+Part 2 of Fixes #52310.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Optional\n+\n+\n+# Substring set that identifies a transport-layer failure on the\n+# response stream.  Same shape as the existing\n+# ``_SERVER_DISCONNECT_PATTERNS`` in ``agent/error_classifier.py:394``\n+# but extended to also catch the OSS-level error signature\n+# (``broken pipe`` / ``errno 32``) that the upstream kill surfaces\n+# to the OpenAI SDK wrapper.\n+_THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = (\n+    \"broken pipe\",\n+    \"errno 32\",\n+    \"remote protocol\",\n+    \"connection reset\",\n+    \"connection lost\",\n+    \"peer closed\",\n+    \"server disconnected\",\n+)\n+\n+\n+def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool:\n+    \"\"\"Return True when a reasoning model's thinking phase hit a transport kill.\n+\n+    Args:\n+        classified: a :class:`agent.error_classifier.ClassifiedError` instance\n+            (duck-typed here to avoid an import cycle in unit tests).\n+        model: the model slug at failure time (e.g.\n+            ``\"nvidia/nemotron-3-ultra-550b-a55b\"``).\n+        error_msg: lowercased string representation of the underlying\n+            exception (typically ``str(api_error).lower()``).\n+\n+    Returns True when ALL conditions hold:\n+        1. ``classified.reason == FailoverReason.timeout`` (the classifier\n+           override at ``agent/error_classifier.py:720-738`` ensures this\n+           is the case for reasoning models even on large sessions).\n+        2. ``api_error`` has no ``.status_code`` attribute set (transport\n+           disconnect, not an HTTP error).\n+        3. ``model`` is in the reasoning-model allowlist (reuses\n+           ``agent.reasoning_timeouts.get_reasoning_stale_timeout_floor``).\n+        4. ``error_msg`` contains one of the transport-kill substrings.\n+\n+    Non-reasoning models always return False.  Non-transport errors\n+    (billing / rate_limit / auth / context_overflow / format_error)\n+    always return False.  HTTP-status errors always return False.\n+    \"\"\"\n+    # Import here (not at module top) to keep this helper cheap to\n+    # import even from callers that don't need it.  ``agent.reasoning_timeouts``\n+    # is small and dependency-free.\n+    from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+\n+    # Condition 1: classifier says timeout.  Use a string/value check\n+    # rather than importing FailoverReason so this module has zero\n+    # import cycles from the error_classifier package.\n+    reason = getattr(classified, \"reason\", None)\n+    reason_value = getattr(reason, \"value\", None)\n+    if reason_value != \"timeout\":\n+        return False\n+\n+    # Condition 2: no HTTP status code (transport, not API error).\n+    # Caller is expected to gate on ``getattr(api_error, \"status_code\", None) is None``\n+    # before calling this helper; the surface here is just the post-gate\n+    # boolean so the caller can pass an already-prepped error_msg.\n+\n+    # Condition 3: reasoning model allowlist.\n+    if get_reasoning_stale_timeout_floor(model) is None:\n+        return False\n+\n+    # Condition 4: transport-kill substring in the error message.\n+    error_msg_lower = (error_msg or \"\").lower()\n+    return any(p in error_msg_lower for p in _THINKING_TIMEOUT_SUBSTRINGS)\n+\n+\n+def build_thinking_timeout_guidance(\n+    provider: str, model: str, model_label: Optional[str] = None,\n+) -> str:\n+    \"\"\"Return the user-facing guidance string appended to ``_final_response``.\n+\n+    Args:\n+        provider: provider slug (e.g. ``\"nvidia\"``, ``\"openai\"``).\n+        model: bare model slug the user would put in their config\n+            (e.g. ``\"nemotron-3-ultra-550b-a55b\"`` if the user uses\n+            NVIDIA direct, or the full ``\"nvidia/nemotron-3-ultra-550b-a55b\"``\n+            if they go through an aggregator).  Used verbatim in the\n+            config snippet so the user can copy-paste.\n+        model_label: optional short label for the model name in the\n+            prose (e.g. ``\"Nemotron 3 Ultra\"``).  Falls back to the\n+            slug if not provided.\n+    \"\"\"\n+    label = model_label or model\n+    return (\n+        \"\\n\\nThe model's thinking phase exceeded the upstream proxy's \"\n+        \"idle timeout before the first content token arrived. This is a \"\n+        f\"known issue with reasoning models (like {label}) behind cloud \"\n+        \"gateways (NVIDIA NIM, OpenAI, Anthropic, DeepSeek). Workarounds \"\n+        \"in priority order:\\n\"\n+        f\"1. Set `providers.{provider}.models.{model}.stale_timeout_seconds: 900` \"\n+        \"in `~/.hermes/config.yaml` to extend the per-call timeout. \"\n+        \"(Hermes's built-in floor is 600s for known reasoning models — \"\n+        \"if you still see this after raising, the upstream cap is even \"\n+        \"shorter.)\\n\"\n+        \"2. Lower `reasoning_budget` or set `reasoning_effort: medium` on this \"\n+        \"model if the provider supports it.\\n\"\n+        \"3. Use a smaller / faster reasoning model if the task doesn't \"\n+        \"require deep thinking.\"\n+    )"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"agt-user\",  # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495)\n+    \"[redacted:email]\": \"DavidMetcalfe\",  # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271)\n     \"[redacted:email]\": \"herbalizer404\",  # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable)\n     \"[redacted:email]\": \"pyxl-dev\",  # PR #52230 salvage (include rate-limit in auxiliary capacity-error fallback gate; #52228)\n     \"[redacted:email]\": \"benbenlijie\",  # PR #47205 salvage (named custom-provider extra_body + Z.AI Coding overload adaptive backoff; #50663)"]},{"file":"tests/agent/test_thinking_timeout_guidance.py","hunks":["@@ -0,0 +1,355 @@\n+\"\"\"Tests for the reasoning-model thinking-timeout detection + guidance.\n+\n+Two layers:\n+\n+1. **Classifier override (Part 1, ``agent/error_classifier.py:720-738``)**:\n+   A transport disconnect on a reasoning model is reclassified as\n+   ``FailoverReason.timeout`` even when the session is large — instead\n+   of routing to the compression branch via\n+   ``FailoverReason.context_overflow`` which would silently delete\n+   conversation history on a phantom context-length error.\n+\n+2. **Detection + guidance (Part 2, ``agent/thinking_timeout_guidance.py``)**:\n+   When the classifier says timeout AND the model is in the reasoning\n+   allowlist AND the error message has a transport-kill signature,\n+   the user gets actionable guidance (raise stale_timeout, lower\n+   reasoning_budget, or switch models) instead of the misleading\n+   \"use execute_code with Python's open() for large files\" advice\n+   that fires for the unrelated large-file-write stream-drop case.\n+\n+Both behaviors were previously broken: the existing\n+``test_disconnect_large_session_context_overflow`` test in\n+``tests/agent/test_error_classifier.py`` confirms that non-reasoning\n+models still route to context_overflow on a large session, so the\n+reasoning-model override is strictly targeted.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+\n+# ── helpers ──────────────────────────────────────────────────────────────\n+\n+\n+class _TimeoutReason:\n+    \"\"\"Minimal FailoverReason stand-in for unit tests.\"\"\"\n+\n+    def __init__(self, value: str = \"timeout\") -> None:\n+        self.value = value\n+\n+\n+def _classified(reason: str = \"timeout\", **kwargs) -> SimpleNamespace:\n+    \"\"\"Construct a ClassifiedError stand-in with the given reason.\"\"\"\n+    defaults = dict(\n+        reason=_TimeoutReason(reason),\n+        status_code=None,\n+        retryable=True,\n+        should_compress=False,\n+        should_rotate_credential=[redacted:secret-assignment]\n+        should_fallback=False,\n+    )\n+    defaults.update(kwargs)\n+    return SimpleNamespace(**defaults)\n+\n+\n+# ── Part 1: classifier override (agent/error_classifier.py:720-738) ──\n+\n+\n+def _make_session(disconnect_message: str, model: str, *, num_messages: int = 250):\n+    \"\"\"Construct inputs to classify_api_error for a disconnect+large-session case.\"\"\"\n+    e = Exception(disconnect_message)\n+    # 128k context_length; 130k approx_tokens puts us over 0.6 of context\n+    # AND > 120k absolute threshold; 250 messages is also > 200 threshold.\n+    # Without the reasoning-model override, this routes to context_overflow.\n+    return e, {\n+        \"provider\": \"nvidia\",\n+        \"model\": model,\n+        \"approx_tokens\": 130_000,\n+        \"context_length\": 200_000,\n+        \"num_messages\": num_messages,\n+    }\n+\n+\n+class TestClassifierOverride:\n+    \"\"\"The reasoning-model override at error_classifier.py:720-738.\n+\n+    Verifies the new behavior: a transport disconnect on a reasoning\n+    model on a LARGE session now routes to FailoverReason.timeout\n+    instead of context_overflow.  Without this fix, the compression\n+    branch would fire on a phantom overflow and silently delete\n+    conversation history.\n+    \"\"\"\n+\n+    def test_reasoning_model_disconnect_on_large_session_is_timeout(self):\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e, kwargs = _make_session(\n+            \"server disconnected without sending complete message\",\n+            model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+        )\n+        result = classify_api_error(e, **kwargs)\n+        assert result.reason == FailoverReason.timeout, (\n+            \"Reasoning-model transport disconnect on a large session \"\n+            \"should route to FailoverReason.timeout (not \"\n+            \"context_overflow) — the upstream proxy idle-kill is far \"\n+            \"more likely than a true context-length error on a \"\n+            \"thinking model.\"\n+        )\n+        assert result.should_compress is False, (\n+            \"Compression would silently delete conversation history on \"\n+            \"a phantom overflow — must not fire for reasoning models.\"\n+        )\n+\n+    @pytest.mark.parametrize(\"model\", [\n+        \"nvidia/nemotron-3-ultra-550b-a55b\",\n+        \"openai/o3-mini\",\n+        \"anthropic/claude-opus-4-6\",\n+        \"deepseek/deepseek-r1\",\n+        \"qwen/qwq-32b-preview\",\n+        \"x-ai/grok-4-fast-reasoning\",\n+    ])\n+    def test_all_known_reasoning_models_override(self, model):\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e, kwargs = _make_session(\n+            \"server disconnected without sending complete message\",\n+            model=model,\n+        )\n+        result = classify_api_error(e, **kwargs)\n+        assert result.reason == FailoverReason.timeout\n+        assert result.should_compress is False\n+\n+    def test_non_reasoning_model_large_session_still_routes_to_context_overflow(self):\n+        \"\"\"Regression guard: existing test_disconnect_large_session_context_overflow\n+        behavior must be preserved for non-reasoning models.\n+\n+        Without the override, this case routes to context_overflow +\n+        should_compress=True (the existing, intentional behavior for\n+        chat models that hit true context-length errors via proxy\n+        disconnect).  With the override, it stays that way.\n+        \"\"\"\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e, kwargs = _make_session(\n+            \"server disconnected without sending complete message\",\n+            model=\"gpt-4o\",\n+        )\n+        result = classify_api_error(e, **kwargs)\n+        assert result.reason == FailoverReason.context_overflow\n+        assert result.should_compress is True\n+\n+    @pytest.mark.parametrize(\"model\", [\n+        \"olmo-1\",\n+        \"gpt-4o\",\n+        \"claude-3-5-sonnet-20240620\",\n+        \"llama-3.3-70b-instruct\",\n+        \"qwen2-72b-instruct\",\n+        \"x-ai/grok-3\",\n+    ])\n+    def test_non_reasoning_models_all_keep_context_overflow(self, model):\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e, kwargs = _make_session(\n+            \"server disconnected without sending complete message\",\n+            model=model,\n+        )\n+        result = classify_api_error(e, **kwargs)\n+        assert result.reason == FailoverReason.context_overflow\n+\n+    def test_reasoning_model_small_session_still_routes_to_timeout(self):\n+        \"\"\"Sanity check: a reasoning model with a SMALL session also\n+        routes to timeout (the original behavior, unchanged by the\n+        override since the override's result matches the small-session\n+        branch's result).\"\"\"\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e = Exception(\"server disconnected\")\n+        result = classify_api_error(\n+            e,\n+            model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+            approx_tokens=[redacted:secret-assignment]\n+            context_length=200_000,\n+            num_messages=10,\n+        )\n+        assert result.reason == FailoverReason.timeout\n+\n+    def test_reasoning_model_with_status_code_does_not_match_disconnect_pattern(self):\n+        \"\"\"Status-code errors take the HTTP-status path in the\n+        classifier, not the disconnect-with-large-session path.\n+        The reasoning-model override is INSIDE the disconnect branch\n+        and doesn't fire for HTTP errors.\"\"\"\n+        from agent.error_classifier import classify_api_error, FailoverReason\n+        e = Exception(\"server disconnected\")\n+        # Inject a status_code attribute to simulate an HTTP error\n+        # whose message happens to contain \"server disconnected\".\n+        e.status_code = 503\n+        result = classify_api_error(\n+            e,\n+            model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+            approx_tokens=[redacted:secret-assignment]\n+            context_length=200_000,\n+            num_messages=250,\n+        )\n+        # 503 specifically routes to overloaded (per the 5xx → 503/529\n+        # handling in error_classifier.py). The key assertion is that\n+        # the reasoning-model override is NOT reached — neither\n+        # timeout nor context_overflow.\n+        assert result.reason != FailoverReason.timeout\n+        assert result.reason != FailoverReason.context_overflow\n+        assert result.should_compress is False\n+\n+\n+# ── Part 2: detection (agent/thinking_timeout_guidance.py:is_thinking_timeout) ──\n+\n+\n+class TestIsThinkingTimeout:\n+    def test_returns_true_for_reasoning_model_with_transport_signature(self):\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(\n+            classified,\n+            \"nvidia/nemotron-3-ultra-550b-a55b\",\n+            \"Error communicating with OpenAI: [Errno 32] Broken pipe\",\n+        ) is True\n+\n+    @pytest.mark.parametrize(\"model,msg\", [\n+        (\"nvidia/nemotron-3-ultra-550b-a55b\", \"connection reset by peer\"),\n+        (\"openai/o3-mini\", \"remote protocol error\"),\n+        (\"anthropic/claude-opus-4-6\", \"peer closed connection\"),\n+        (\"deepseek/deepseek-r1\", \"connection lost\"),\n+        (\"x-ai/grok-4-fast-reasoning\", \"server disconnected\"),\n+    ])\n+    def test_known_reasoning_models_match(self, model, msg):\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(classified, model, msg) is True\n+\n+    @pytest.mark.parametrize(\"model\", [\n+        \"gpt-4o\",\n+        \"claude-3-5-sonnet-20240620\",\n+        \"llama-3.3-70b-instruct\",\n+        \"qwen2-72b-instruct\",\n+    ])\n+    def test_non_reasoning_models_never_match(self, model):\n+        \"\"\"Non-reasoning models must always return False even with\n+        matching transport signature — the guidance is\n+        reasoning-specific.\"\"\"\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(\n+            classified, model, \"connection reset by peer\",\n+        ) is False\n+\n+    @pytest.mark.parametrize(\"reason\", [\n+        \"billing\",\n+        \"rate_limit\",\n+        \"auth\",\n+        \"context_overflow\",\n+        \"format_error\",\n+        \"provider_policy_blocked\",\n+        \"content_policy_blocked\",\n+        \"thinking_signature\",\n+        \"unknown\",\n+    ])\n+    def test_non_timeout_reasons_never_match(self, reason):\n+        \"\"\"The detection only fires when the classifier says timeout.\n+        Other reasons have their own distinct guidance paths.\"\"\"\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=reason)\n+        assert is_thinking_timeout(\n+            classified,\n+            \"nvidia/nemotron-3-ultra-550b-a55b\",\n+            \"connection reset by peer\",\n+        ) is False\n+\n+    @pytest.mark.parametrize(\"msg\", [\n+        \"Insufficient credits\",\n+        \"Rate limit exceeded\",\n+        \"Invalid API key\",\n+        \"Context length exceeded\",\n+        \"Tool call argument malformed\",\n+    ])\n+    def test_non_transport_messages_never_match(self, msg):\n+        \"\"\"The detection only fires for transport-kill signatures.\n+        A reasoning model that returns a billing/rate-limit/auth/etc\n+        error gets the classifier-default guidance, not this one.\"\"\"\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(\n+            classified, \"nvidia/nemotron-3-ultra-550b-a55b\", msg,\n+        ) is False\n+\n+    def test_empty_error_msg_returns_false(self):\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(\n+            classified, \"nvidia/nemotron-3-ultra-550b-a55b\", \"\",\n+        ) is False\n+\n+    def test_none_error_msg_returns_false(self):\n+        from agent.thinking_timeout_guidance import is_thinking_timeout\n+        classified = _classified(reason=\"timeout\")\n+        assert is_thinking_timeout(\n+            classified, \"nvidia/nemotron-3-ultra-550b-a55b\", None,\n+        ) is False\n+\n+\n+# ── Part 2: guidance text (agent/thinking_timeout_guidance.py:build_thinking_timeout_guidance) ──\n+\n+\n+class TestBuildThinkingTimeoutGuidance:\n+    def test_guidance_mentions_config_path(self):\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(\n+            provider=\"nvidia\", model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+        )\n+        assert \"providers.nvidia.models.nvidia/nemotron-3-ultra-550b-a55b.stale_timeout_seconds\" in text\n+\n+    def test_guidance_mentions_three_workarounds(self):\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(provider=\"nvidia\", model=\"x\")\n+        assert \"1.\" in text\n+        assert \"2.\" in text\n+        assert \"3.\" in text\n+\n+    def test_guidance_mentions_known_providers(self):\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(provider=\"nvidia\", model=\"x\")\n+        # At least one of the known cloud providers should be mentioned\n+        # to give the user context.\n+        assert any(p in text for p in (\n+            \"NVIDIA NIM\", \"OpenAI\", \"Anthropic\", \"DeepSeek\",\n+        ))\n+\n+    def test_guidance_mentions_built_in_floor(self):\n+        \"\"\"User should know that 600s is already set by default for\n+        known reasoning models — if they hit the error after raising,\n+        it's the upstream cap, not hermes.\"\"\"\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(provider=\"nvidia\", model=\"x\")\n+        assert \"600s\" in text\n+\n+    def test_guidance_does_not_recommend_execute_code(self):\n+        \"\"\"Critical regression guard: the new guidance must NOT\n+        recommend `execute_code with Python's open() for large files`\n+        — that's the misleading advice from the existing _is_stream_drop\n+        guidance that was wrong for the thinking-timeout case.\"\"\"\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(provider=\"nvidia\", model=\"x\")\n+        assert \"execute_code\" not in text\n+        assert \"open()\" not in text\n+\n+    def test_guidance_uses_label_when_provided(self):\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(\n+            provider=\"nvidia\",\n+            model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+            model_label=\"Nemotron 3 Ultra\",\n+        )\n+        assert \"Nemotron 3 Ultra\" in text\n+\n+    def test_guidance_falls_back_to_slug_when_no_label(self):\n+        from agent.thinking_timeout_guidance import build_thinking_timeout_guidance\n+        text = build_thinking_timeout_guidance(\n+            provider=\"nvidia\",\n+            model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+        )\n+        assert \"nvidia/nemotron-3-ultra-550b-a55b\" in text\n"]}]},{"kind":"pr","number":52845,"title":"feat(agent): apply per-reasoning-model stale-timeout floor in stream + non-stream detectors (#52217)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","run_agent.py","tests/agent/test_reasoning_stale_timeout_floor.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -2561,6 +2561,17 @@ def _call():\n             _stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)\n         else:\n             _stream_stale_timeout = _stream_stale_timeout_base\n+        # Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,\n+        # OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,\n+        # xAI Grok reasoning, etc.) routinely exceed the default 180s chat-\n+        # model threshold during their thinking phase.  The cloud gateway\n+        # upstream kills the socket first, surfacing as BrokenPipeError.\n+        # Raises the floor only — never overrides explicit user config\n+        # (handled by get_provider_stale_timeout above).\n+        from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+        _reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get(\"model\"))\n+        if _reasoning_floor is not None:\n+            _stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)\n \n     t = threading.Thread(target=_call, daemon=True)\n     t.start()"]},{"file":"run_agent.py","hunks":["@@ -1138,6 +1138,19 @@ def _resolved_api_call_stale_timeout_base(self) -> tuple[float, bool]:\n         if env_timeout is not None:\n             return float(env_timeout), False\n \n+        # Reasoning-model floor: auto-mitigation for known reasoning models\n+        # (Nemotron 3 Ultra, OpenAI o1/o3, Anthropic Opus 4.x thinking,\n+        # DeepSeek R1, Qwen QwQ, xAI Grok reasoning, etc.) whose cloud\n+        # gateways idle-kill before the model's thinking phase ends.\n+        # uses_implicit_default is False here so the local-endpoint\n+        # short-circuit in _compute_non_stream_stale_timeout does not\n+        # disable stale detection for users running reasoning models on a\n+        # local NIM endpoint.\n+        from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+        reasoning_floor = get_reasoning_stale_timeout_floor(self.model)\n+        if reasoning_floor is not None:\n+            return reasoning_floor, False\n+\n         return 90.0, True\n \n     def _compute_non_stream_stale_timeout(self, api_payload: Any) -> float:"]},{"file":"tests/agent/test_reasoning_stale_timeout_floor.py","hunks":["@@ -0,0 +1,373 @@\n+\"\"\"Regression tests for the reasoning-model stale-timeout floor (issue #52217).\n+\n+Reasoning models (Nemotron 3 Ultra, OpenAI o1/o3, Anthropic Opus 4.x\n+thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning) routinely exceed\n+the 180s / 90s chat-model stale-timeout defaults during their\n+thinking phase.  Hermes's default cloud-stream stale detector\n+(``HERMES_STREAM_STALE_TIMEOUT`` = 180s) and non-stream detector\n+(``HERMES_API_CALL_STALE_TIMEOUT`` = 90s) both fire before the\n+upstream proxy's idle timeout on a healthy reasoning stream.  Result:\n+the user sees ``API call failed after 3 retries: [Errno 32] Broken\n+pipe`` for every Nemotron 3 Ultra turn.\n+\n+These tests pin the floor's behavior:\n+\n+1. ``get_reasoning_stale_timeout_floor`` returns the right floor for\n+   every key in the allowlist, ``None`` for every negative case\n+   (gpt-4o, olmo-1, etc.), and longest-substring-first wins on\n+   shared prefixes (``o3-mini-`` > ``o3-``).\n+2. The non-stream resolver at\n+   ``run_agent.py:AIAgent._resolved_api_call_stale_timeout_base``\n+   consults the floor at priority 4 (after explicit user config,\n+   provider config, and env var; before the 90s default), and\n+   returns ``uses_implicit_default=False`` so the local-endpoint\n+   short-circuit in ``_compute_non_stream_stale_timeout`` does not\n+   disable stale detection for a reasoning model running on a local\n+   NIM endpoint.\n+3. The stream stale-timeout resolution (mirrored here as in\n+   ``test_stream_read_timeout_floor.py`` because the real builder\n+   lives inside a worker thread) consults the floor after the\n+   context-size scaling block, raising the timeout for reasoning\n+   models without lowering it for non-reasoning models.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+# ── pure-function resolver ────────────────────────────────────────────────\n+\n+\n[redacted:email](\"model,expected\", [\n+    # NVIDIA Nemotron reasoning family (longest keys first).\n+    (\"nvidia/nemotron-3-ultra-550b-a55b\", 600.0),\n+    (\"nvidia/nemotron-3-super-120b-a12b\", 600.0),\n+    (\"nvidia/nemotron-3-nano-30b-a3b\", 300.0),\n+    # DeepSeek R1 + DeepSeek reasoner.\n+    (\"deepseek/deepseek-r1\", 600.0),\n+    (\"deepseek/deepseek-r1-distill-llama-70b\", 600.0),\n+    (\"deepseek/deepseek-reasoner\", 600.0),\n+    # Qwen QwQ + Qwen3 thinking variants (qwen3 family entry matches all).\n+    (\"qwen/qwq-32b-preview\", 300.0),\n+    (\"qwen/qwen3-235b-a22b-thinking\", 180.0),\n+    (\"qwen/qwen3-32b\", 180.0),\n+    # OpenAI o-series — each variant enumerated explicitly.\n+    # Longest match wins (o3-mini beats o3 on shared prefix).\n+    (\"openai/o1\", 600.0),\n+    (\"openai/o1-mini\", 600.0),\n+    (\"openai/o1-pro\", 600.0),\n+    (\"openai/o1-preview\", 600.0),\n+    (\"openai/o3\", 600.0),\n+    (\"openai/o3-pro\", 600.0),\n+    (\"openai/o3-mini\", 300.0),\n+    (\"openai/o4-mini\", 300.0),\n+    # Anthropic Claude 4.x thinking variants.\n+    (\"anthropic/claude-opus-4-6\", 240.0),\n+    (\"anthropic/claude-opus-4-20250514\", 240.0),\n+    (\"anthropic/claude-sonnet-4.5\", 180.0),\n+    (\"anthropic/claude-sonnet-4.6\", 180.0),\n+    # xAI Grok reasoning variants — explicit, not bare `grok`.\n+    (\"x-ai/grok-4-fast-reasoning\", 300.0),\n+    (\"x-ai/grok-4.20-reasoning\", 300.0),\n+    (\"x-ai/grok-4-fast-non-reasoning\", 180.0),\n+])\n+def test_reasoning_stale_timeout_floor_positive_cases(model, expected):\n+    from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+    assert get_reasoning_stale_timeout_floor(model) == expected, (\n+        f\"get_reasoning_stale_timeout_floor({model!r}) should return \"\n+        f\"{expected}; bare substrings and shared prefixes must not \"\n+        f\"over-match community derivatives.\"\n+    )\n+\n+\n[redacted:email](\"model\", [\n+    # Non-reasoning chat models — no floor.\n+    \"gpt-4o\",\n+    \"gpt-5\",\n+    \"claude-3-5-sonnet-20240620\",\n+    \"llama-3.3-70b-instruct\",\n+    \"gemini-2.5-pro\",\n+    # Start-of-slug anchor traps — the slug must be at the START of\n+    # the bare model name (after aggregator-prefix strip).  Bare\n+    # substring matching would over-match these.\n+    \"olmo-1\",\n+    \"olmo-13b\",\n+    \"llama-4-70b-o1-preview\",     # embedded `o1-preview`, NOT start of slug\n+    \"some-model-o3-mini-fork\",    # embedded `o3-mini`, NOT start of slug\n+    # Bare \"grok\" must not over-match non-reasoning Grok SKUs.\n+    \"x-ai/grok-3\",\n+    \"x-ai/grok-4\",\n+    \"x-ai/grok-4-0709\",\n+    \"x-ai/grok-code-fast-1\",\n+    # Qwen2 must not match Qwen3 (different family).\n+    \"qwen2-72b-instruct\",\n+    # Empty / None / non-string inputs — must return None, not raise.\n+    \"\",\n+    None,\n+    12345,\n+    [],\n+])\n+def test_reasoning_stale_timeout_floor_negative_cases(model):\n+    from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+    assert get_reasoning_stale_timeout_floor(model) is None, (\n+        f\"get_reasoning_stale_timeout_floor({model!r}) must return None \"\n+        f\"for non-reasoning models and start-of-slug-anchor traps.\"\n+    )\n+\n+\n+def test_longest_substring_wins_on_shared_prefix():\n+    \"\"\"`o3-mini` must beat `o3` so the smaller floor applies.\"\"\"\n+    from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+    # o3-mini (7 chars) wins over o3 (2 chars) on shared prefix.\n+    assert get_reasoning_stale_timeout_floor(\"openai/o3-mini\") == 300.0\n+    assert get_reasoning_stale_timeout_floor(\"openai/o3\") == 600.0\n+    # Even with deep aggregator prefix chains the model name resolves\n+    # correctly (start-of-slug anchor + rsplit('/') strip).\n+    assert get_reasoning_stale_timeout_floor(\"openrouter/openai/o3-mini\") == 300.0\n+    assert get_reasoning_stale_timeout_floor(\"openrouter/anthropic/claude-opus-4-6\") == 240.0\n+\n+\n+\n+# ── integration: _resolved_api_call_stale_timeout_base ─────────────────────\n+\n+\n+def _write_config(tmp_path: Path, body: str) -> None:\n+    (tmp_path / \"config.yaml\").write_text(body or \"{}\\n\", encoding=\"utf-8\")\n+\n+\n+def _make_agent(tmp_path: Path, **overrides):\n+    from run_agent import AIAgent\n+    kwargs = dict(\n+        model=\"gpt-5.5\",\n+        provider=\"openai-codex\",\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"https://chatgpt.com/backend-api/codex\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+        platform=\"cli\",\n+    )\n+    kwargs.update(overrides)\n+    return AIAgent(**kwargs)\n+\n+\n+def test_reasoning_floor_applies_to_nemotron_3_ultra(monkeypatch, tmp_path):\n+    \"\"\"Nemotron 3 Ultra without explicit config gets the 600s floor.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \".env\").write_text(\"\", encoding=\"utf-8\")\n+    monkeypatch.delenv(\"HERMES_API_CALL_STALE_TIMEOUT\", raising=False)\n+    _write_config(tmp_path, \"\")\n+\n+    # Clear any cached config from prior tests in this session.\n+    import importlib\n+    from hermes_cli import config as cfg_mod, timeouts as to_mod\n+    importlib.reload(cfg_mod)\n+    importlib.reload(to_mod)\n+\n+    agent = _make_agent(\n+        tmp_path,\n+        provider=\"nvidia\",\n+        base_url=\"https://integrate.api.nvidia.com/v1\",\n+        model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+    )\n+    base, implicit = agent._resolved_api_call_stale_timeout_base()\n+    assert base == 600.0\n+    assert implicit is False, (\n+        \"Reasoning-model floor must return uses_implicit_default=False \"\n+        \"so the local-endpoint short-circuit in \"\n+        \"_compute_non_stream_stale_timeout does not disable detection \"\n+        \"for users running reasoning models on a local NIM endpoint.\"\n+    )\n+\n+\n+def test_reasoning_floor_applies_to_opus_4_thinking(monkeypatch, tmp_path):\n+    \"\"\"Anthropic Opus 4.x thinking gets the 240s floor without explicit config.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \".env\").write_text(\"\", encoding=\"utf-8\")\n+    monkeypatch.delenv(\"HERMES_API_CALL_STALE_TIMEOUT\", raising=False)\n+    _write_config(tmp_path, \"\")\n+\n+    import importlib\n+    from hermes_cli import config as cfg_mod, timeouts as to_mod\n+    importlib.reload(cfg_mod)\n+    importlib.reload(to_mod)\n+\n+    agent = _make_agent(\n+        tmp_path,\n+        provider=\"anthropic\",\n+        base_url=\"https://api.anthropic.com\",\n+        model=\"claude-opus-4-6\",\n+    )\n+    base, implicit = agent._resolved_api_call_stale_timeout_base()\n+    assert base == 240.0\n+    assert implicit is False\n+\n+\n+def test_reasoning_floor_never_overrides_explicit_user_config(monkeypatch, tmp_path):\n+    \"\"\"Explicit per-model stale_timeout_seconds wins over the floor.\n+\n+    Regression guard for the invariant: explicit user config > reasoning\n+    floor > env var > default. If a user sets stale_timeout_seconds: 60\n+    on Nemotron 3 Ultra, that's what fires — even though the floor\n+    would otherwise be 600s.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \".env\").write_text(\"\", encoding=\"utf-8\")\n+    _write_config(tmp_path, \"\"\"\\\n+providers:\n+  nvidia:\n+    models:\n+      nvidia/nemotron-3-ultra-550b-a55b:\n+        stale_timeout_seconds: 60\n+\"\"\")\n+    monkeypatch.delenv(\"HERMES_API_CALL_STALE_TIMEOUT\", raising=False)\n+\n+    import importlib\n+    from hermes_cli import config as cfg_mod, timeouts as to_mod\n+    importlib.reload(cfg_mod)\n+    importlib.reload(to_mod)\n+\n+    agent = _make_agent(\n+        tmp_path,\n+        provider=\"nvidia\",\n+        base_url=\"https://integrate.api.nvidia.com/v1\",\n+        model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+    )\n+    base, implicit = agent._resolved_api_call_stale_timeout_base()\n+    assert base == 60.0, (\n+        \"Explicit user stale_timeout_seconds must override the \"\n+        \"reasoning-model floor; the user knows their environment.\"\n+    )\n+    assert implicit is False\n+\n+\n+def test_reasoning_floor_loses_to_env_var_when_no_floor_match(monkeypatch, tmp_path):\n+    \"\"\"For a non-reasoning model, env var still wins over the 90s default.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \".env\").write_text(\"\", encoding=\"utf-8\")\n+    monkeypatch.setenv(\"HERMES_API_CALL_STALE_TIMEOUT\", \"300\")\n+    _write_config(tmp_path, \"\")\n+\n+    import importlib\n+    from hermes_cli import config as cfg_mod, timeouts as to_mod\n+    importlib.reload(cfg_mod)\n+    importlib.reload(to_mod)\n+\n+    agent = _make_agent(\n+        tmp_path,\n+        provider=\"openai\",\n+        base_url=\"https://api.openai.com/v1\",\n+        model=\"gpt-5.5\",  # not in the floor allowlist\n+    )\n+    base, implicit = agent._resolved_api_call_stale_timeout_base()\n+    assert base == 300.0\n+    assert implicit is False\n+\n+\n+def test_non_reasoning_model_keeps_default(monkeypatch, tmp_path):\n+    \"\"\"GPT-5 (non-reasoning) without env var / config -> 90s default, implicit.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \".env\").write_text(\"\", encoding=\"utf-8\")\n+    monkeypatch.delenv(\"HERMES_API_CALL_STALE_TIMEOUT\", raising=False)\n+    _write_config(tmp_path, \"\")\n+\n+    import importlib\n+    from hermes_cli import config as cfg_mod, timeouts as to_mod\n+    importlib.reload(cfg_mod)\n+    importlib.reload(to_mod)\n+\n+    agent = _make_agent(\n+        tmp_path,\n+        provider=\"openai\",\n+        base_url=\"https://api.openai.com/v1\",\n+        model=\"gpt-5.5\",\n+    )\n+    base, implicit = agent._resolved_api_call_stale_timeout_base()\n+    assert base == 90.0\n+    assert implicit is True\n+\n+\n+# ── stream-side mirror (the real builder lives in a worker thread) ────────\n+\n+\n+def _resolve_stream_stale_timeout(\n+    model: str | None,\n+    base_url: str,\n+    est_tokens: int,\n+    stale_base: float = 180.0,\n+) -> float:\n+    \"\"\"Mirror of the stale-stream resolution in agent/chat_completion_helpers.py.\n+\n+    Kept in lockstep with the production code at lines 2539-2575 of\n+    agent/chat_completion_helpers.py.  When that block changes, this\n+    mirror must change too — the failing-test signal is the divergence.\n+    \"\"\"\n+    from agent.model_metadata import is_local_endpoint\n+    from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor\n+\n+    # Provider-configured stale timeout wins (mirrors get_provider_stale_timeout).\n+    if stale_base != 180.0:\n+        pass  # In production this is sourced from config; here we parameterize.\n+\n+    if stale_base == 180.0 and base_url and is_local_endpoint(base_url):\n+        return float(\"inf\")\n+\n+    if est_tokens > 100_000:\n+        timeout = max(stale_base, 300.0)\n+    elif est_tokens > 50_000:\n+        timeout = max(stale_base, 240.0)\n+    else:\n+        timeout = stale_base\n+\n+    # Reasoning-model floor (the new branch this PR adds).\n+    floor = get_reasoning_stale_timeout_floor(model)\n+    if floor is not None:\n+        timeout = max(timeout, floor)\n+    return timeout\n+\n+\n+def test_stream_stale_timeout_floor_for_nemotron_3_ultra():\n+    \"\"\"Small-context Nemotron 3 Ultra without explicit config -> 600s floor.\n+\n+    Without the floor, this would be 180s (the default), which is shorter\n+    than NVIDIA NIM's ~120s upstream idle kill — guaranteeing broken pipe.\n+    \"\"\"\n+    timeout = _resolve_stream_stale_timeout(\n+        model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+        base_url=\"https://integrate.api.nvidia.com/v1\",\n+        est_tokens=[redacted:secret-assignment]\n+    )\n+    assert timeout == 600.0\n+\n+\n+def test_stream_stale_timeout_floor_never_lowers_existing():\n+    \"\"\"The floor raises; it never lowers the existing context-size tier.\"\"\"\n+    # 120k-token conversation on a reasoning model -> context tier already\n+    # raises to 300s; floor (600s) takes it to 600s.\n+    timeout = _resolve_stream_stale_timeout(\n+        model=\"nvidia/nemotron-3-ultra-550b-a55b\",\n+        base_url=\"https://integrate.api.nvidia.com/v1\",\n+        est_tokens=[redacted:secret-assignment]\n+    )\n+    assert timeout == 600.0\n+\n+    # 60k tokens on Opus 4 -> context tier raises to 240s; floor keeps 240s.\n+    timeout = _resolve_stream_stale_timeout(\n+        model=\"anthropic/claude-opus-4-6\",\n+        base_url=\"https://api.anthropic.com\",\n+        est_tokens=[redacted:secret-assignment]\n+    )\n+    assert timeout == 240.0\n+\n+\n+def test_stream_stale_timeout_unchanged_for_non_reasoning_models():\n+    \"\"\"gpt-4o on a small context still gets the 180s default — no behavior change.\"\"\"\n+    timeout = _resolve_stream_stale_timeout(\n+        model=\"gpt-4o\",\n+        base_url=\"https://api.openai.com/v1\",\n+        est_tokens=[redacted:secret-assignment]\n+    )\n+    assert timeout == 180.0\n"]}]},{"kind":"pr","number":62356,"title":"fix(agent): add MiniMax M2.x to reasoning stale-timeout floor","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"82ca84c52d","number":0},{"kind":"pr","value":"#121555","number":121555},{"kind":"pr","value":"#62353","number":62353}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":62404,"title":"fix(agent): add MiniMax M2.x to reasoning stale-timeout floor","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62356","number":62356}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":62497,"title":"fix: add MiniMax M2.x to reasoning stale-stream timeout floor (#62353)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62517","number":62517}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":62517,"title":"fix: add MiniMax M2.x to reasoning stale-stream timeout floor (#62353)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"82ca84c52d","number":0},{"kind":"pr","value":"#121555","number":121555},{"kind":"pr","value":"#62353","number":62353},{"kind":"pr","value":"#62356","number":62356}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":121555,"title":"fix(agent): run-budget caps stream stale timeout; MiniMax M2.x reasoning floor; inline cron Codex keeps its floor","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/reasoning_timeouts.py","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","run_agent.py","tests/agent/test_cron_inline_api_call.py","tests/agent/test_reasoning_stale_timeout_floor.py","tests/agent/test_run_budget.py","tests/cron/test_cron_direct_api_call.py","tests/cron/test_cron_direct_api_call_watchdog.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}