Files
hermes-agent/agent/reasoning_timeouts.py
liuhao1024 40d6529dc8 fix(agent): add MiniMax M2.x to reasoning stale-timeout floor
MiniMax M2.x reasoning models emit reasoning_content blocks before
their first content token (#17924). During extended thinking phases,
they routinely exceed the default 180s chat-model stale-stream
timeout, causing the stale-stream detector to kill the connection
mid-think.

This adds minimax-m2 to _REASONING_STALE_TIMEOUT_FLOORS with a
300s floor — generous enough to cover the documented 240s stall
(test_streaming.py:1270-1278) with margin.

Fixes #62353

(cherry picked from commit d66b2a75dee78f583edb75b699ba055ffcb93013)
2026-09-25 21:28:32 +05:30

83 lines
4.4 KiB
Python

"""Per-model stale-timeout FLOOR for known reasoning models.
Reasoning models routinely exceed the default chat-model stale detectors (stream 180s,
non-stream 90s): upstream proxies idle-kill the stream mid-think, surfacing as
``BrokenPipeError``/``RemoteProtocolError``. The stale-detector scaling applies
``max(default, floor)`` from :func:`get_reasoning_stale_timeout_floor`, so this never
overrides explicit per-model ``stale_timeout_seconds``/``request_timeout_seconds`` (that
branch never calls it), never lowers a threshold, and is ``None`` for non-allowlisted models.
"""
from __future__ import annotations
import re
from typing import Optional
# floor_seconds -> slugs. Order irrelevant — longest slug wins at match time.
_REASONING_STALE_TIMEOUT_FLOORS: dict[int, tuple[str, ...]] = {
600: (
# NVIDIA Nemotron behind hosted NIM: documented 60-180s upstream idle kill.
"nemotron-3-ultra", "nemotron-3-super",
# DeepSeek R1 / V4 (reasoning_content streamed before final content).
# ``deepseek-flash`` is the version-less canonical Flash id (2026-09 Flash refresh);
# ``deepseek-v4-flash`` still aliases onto it server-side.
"deepseek-r1", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4.1-flash", "deepseek-v4-pro",
# OpenAI o-series: each variant enumerated so bare ``o1`` cannot over-match ``olmo-1``.
"o1", "o1-mini", "o1-pro", "o1-preview", "o3", "o3-pro",
# OpenAI named reasoning lines (gpt-5.6-sol/-terra/-luna, gpt-6-astra, their -pro/-900k
# variants): minutes-long thinking at xhigh/max/ultra; sub-10k-token requests sit below the
# Codex context-size floor, so this is their only protection. Anchored so gpt-5.5 and the
# gpt-4.x / gpt-5.1-chat lines keep the effort-tier defaults (#112909).
"gpt-5.6", "gpt-6",
# Mythos-class named models (claude-fable-5): 1M ctx + 128K output, a heavier thinking
# phase than the numbered line — otherwise the stale detector trips the circuit breaker.
"claude-fable",
),
300: (
"nemotron-3-nano", "nemotron-3.5-lightning", "qwq-32b", "o3-mini", "o4-mini",
# xAI Grok: explicit reasoning pairs only, so bare ``grok-3``/``grok-4`` fast variants
# don't inherit the floor.
"grok-4-fast-reasoning", "grok-4.20-reasoning", "grok-4.5", "grok-4.6",
# "Ox Alpha" stealth reasoning model (OpenRouter / OpenCode Zen slugs); Thinking
# Machines Inkling (covers inkling-small and :free SKUs).
"ox-alpha", "x-preview-f-free", "inkling",
# MiniMax M2.x (m2.5/m2.7): reasoning_content before first content token; 240s
# mid-think stalls observed (#62353).
"minimax-m2",
),
# Anthropic Claude 4.x+ thinking variants (anchored so 3.x never matches).
240: ("claude-opus-4", "claude-opus-5"),
# qwen3 family: instruct variants also match — a slightly longer wait on a hung provider
# beats a pattern (``qwen3-.*-thinking``) that breaks on the next naming shape.
180: ("qwen3", "claude-sonnet-5", "claude-sonnet-4.5", "claude-sonnet-4.6", "grok-4-fast-non-reasoning"),
}
# Pre-compiled once at import (immutable afterwards — safe under free-threaded Python).
# Right anchor: end-of-string or a slug separator; ``:`` because OpenRouter routing suffixes
# (``:free``, ``:nitro``) attach directly to the slug. Longest-first so ``o3-mini`` beats ``o3``.
_SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [
(slug, floor, re.compile(r"^" + re.escape(slug) + r"(?:$|[\-._:])"))
for slug, floor in sorted(
((slug, floor) for floor, slugs in _REASONING_STALE_TIMEOUT_FLOORS.items() for slug in slugs),
key=lambda kv: -len(kv[0]),
)
]
def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
"""Stale-timeout floor (seconds) for a known reasoning model, else ``None``.
The aggregator prefix (up to the last ``/``) is stripped and the slug matched
start-anchored with an end-or-separator right anchor, so ``qwen3-235b`` matches ``qwen3``
but ``some-other-qwen3`` and ``llama-4-70b-o1-preview`` do not.
"""
if not model or not isinstance(model, str):
return None
name = model.strip().lower().rsplit("/", 1)[-1]
for _slug, floor, pattern in _SORTED_REASONING_FLOORS:
if pattern.search(name):
return float(floor)
return None