Files
hermes-agent/agent/reasoning_params.py

295 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Provider reasoning-parameter policy for ``AIAgent``.
When ``reasoning`` extra_body is safe to send, LM Studio / Ollama / GitHub Models capability probes,
``reasoning_content`` echo families, and strict-API tool-call sanitising.
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
"""
from typing import Optional
from agent.lazy_forward import forward as _forward, forward_static as _forward_static
from utils import base_url_host_matches
class ReasoningParamsMixin:
"""Reasoning-parameter gating and echo policy (see module docstring)."""
def _supports_reasoning_extra_body(self) -> bool:
"""Return True when reasoning extra_body is safe to send for this route/model.
OpenRouter forwards unknown extra_body upstream and some routes 400 on ``reasoning``; gate to known
reasoning-capable families and direct Nous Portal.
"""
if base_url_host_matches(self._base_url_lower, "nousresearch.com"):
return True
if base_url_host_matches(self._base_url_lower, "ai-gateway.vercel.sh"):
return True
if (
base_url_host_matches(self._base_url_lower, "models.github.ai")
or base_url_host_matches(self._base_url_lower, "githubcopilot.com")
):
try:
from hermes_cli.models import github_model_reasoning_efforts
return bool(github_model_reasoning_efforts(self.model))
except Exception:
return False
if (self.provider or "").strip().lower() == "lmstudio":
opts = self._lmstudio_reasoning_options_cached()
# "off-only" (or absent) means no real reasoning capability.
return any(opt and opt != "off" for opt in opts)
# Ollama Cloud: /api/show capabilities are authoritative — emit reasoning_effort only for models
# declaring "thinking". Cached per (model, base_url).
if base_url_host_matches(self._base_url_lower, "ollama.com"):
return self._ollama_supports_thinking_cached()
if not self._is_openrouter_url():
return False
if base_url_host_matches(self._base_url_lower, "api.mistral.ai"):
return False
model = (self.model or "").lower()
# Live-catalog metadata first (OpenRouter /v1/models supported_parameters) — the static prefix
# allowlist repeatedly went stale one vendor at a time (#75386). Unknown falls back to the static
# list.
try:
from hermes_cli.models import (
openrouter_model_reasoning_capabilities,
warm_openrouter_reasoning_caps_async,
)
caps = openrouter_model_reasoning_capabilities(self.model)
if caps is None:
# Cache cold — warm in the background; never block this turn on HTTP.
warm_openrouter_reasoning_caps_async()
except Exception:
caps = None
if caps is not None:
return bool(caps.get("supports_reasoning"))
reasoning_model_prefixes = (
"deepseek/",
"anthropic/",
"openai/",
"x-ai/",
"google/gemini-2",
"google/gemma-4",
"qwen/qwen3",
"tencent/hy",
"xiaomi/",
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
def _lmstudio_reasoning_options_cached(self) -> list[str]:
"""Probe LM Studio's published reasoning ``allowed_options`` once per (model, base_url).
Needed for the supports-reasoning gate and to clamp ``reasoning_effort`` so toggle-style models don't
400 on ``high``. Non-empty results cache permanently; empty ones (transient failure OR non-reasoning
model) cache with a 60s TTL to avoid a round-trip per turn while retrying soon.
"""
import time as _time
cache = getattr(self, "_lm_reasoning_opts_cache", None)
if cache is None:
cache = self._lm_reasoning_opts_cache = {}
key = (self.model, self.base_url)
cached = cache.get(key)
if cached is not None:
opts, ts = cached
# Non-empty → permanent. Empty → 60s TTL.
if opts or (_time.monotonic() - ts) < 60:
return opts
try:
from hermes_cli.models import lmstudio_model_reasoning_options
opts = lmstudio_model_reasoning_options(
self.model, self.base_url, getattr(self, "api_key", ""),
)
except Exception:
opts = []
cache[key] = (opts, _time.monotonic())
return opts
def _ollama_supports_thinking_cached(self) -> bool:
"""Probe Ollama's ``/api/show`` capabilities once per (model, base_url); True only if ``thinking`` is
declared.
True/False cache permanently; a probe failure (None) caches 60s so an outage neither suppresses
reasoning for the session nor round-trips every turn.
"""
import time as _time
cache = getattr(self, "_ollama_thinking_cache", None)
if cache is None:
cache = self._ollama_thinking_cache = {}
key = (self.model, self.base_url)
cached = cache.get(key)
if cached is not None:
supported, ts = cached
# Definitive True/False → permanent. Unknown (None) → 60s TTL.
if supported is not None or (_time.monotonic() - ts) < 60:
return bool(supported)
try:
from hermes_cli.models import ollama_model_supports_thinking
supported = ollama_model_supports_thinking(
self.model, self.base_url, getattr(self, "api_key", "")
)
except Exception:
supported = None
cache[key] = (supported, _time.monotonic())
return bool(supported)
def _resolve_lmstudio_summary_reasoning_effort(self) -> Optional[str]:
"""Resolve a safe top-level ``reasoning_effort`` for LM Studio.
The iteration-limit summary calls ``chat.completions.create()`` directly, bypassing the transport;
share the helper so effort resolution and clamping cannot drift.
"""
from agent.lmstudio_reasoning import resolve_lmstudio_effort
return resolve_lmstudio_effort(
self.reasoning_config,
self._lmstudio_reasoning_options_cached(),
)
def _github_models_reasoning_extra_body(self) -> dict | None:
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
try:
from hermes_cli.models import github_model_reasoning_efforts
except Exception:
return None
supported_efforts = github_model_reasoning_efforts(self.model)
if not supported_efforts:
return None
if self.reasoning_config and isinstance(self.reasoning_config, dict):
if self.reasoning_config.get("enabled") is False:
return None
requested_effort = str(
self.reasoning_config.get("effort", "medium")
).strip().lower()
else:
requested_effort = "medium"
if requested_effort == "xhigh" and "xhigh" not in supported_efforts and "high" in supported_efforts:
requested_effort = "high"
elif requested_effort not in supported_efforts:
if requested_effort == "minimal" and "low" in supported_efforts:
requested_effort = "low"
elif "medium" in supported_efforts:
requested_effort = "medium"
else:
requested_effort = supported_efforts[0]
return {"effort": requested_effort}
_build_assistant_message = _forward("agent.chat_completion_helpers", "build_assistant_message")
def _needs_thinking_reasoning_pad(self) -> bool:
"""Return True when the active provider enforces ``reasoning_content`` echo-back on tool-call replays.
DeepSeek thinking, Kimi/Moonshot thinking and Xiaomi MiMo thinking all 400 without it. Cached per
(provider, model, base_url) and invalidated by ``switch_model()`` / ``_try_activate_fallback()`` —
the loop calls this ~16× per turn and each miss re-runs several ``urlparse`` host matches.
"""
key = (self.provider, self.model, getattr(self, "_base_url_lower", self.base_url))
cached = getattr(self, "_thinking_pad_cache", None)
if cached is not None and cached[0] == key:
return cached[1]
result = (
self._needs_deepseek_tool_reasoning()
or self._needs_kimi_tool_reasoning()
or self._needs_mimo_tool_reasoning()
or self._reasoning_echo_opt_in()
)
self._thinking_pad_cache = (key, result)
return result
def _reasoning_echo_opt_in(self) -> bool:
"""True when the user opted in to ``reasoning_content`` echo-back for the *current* provider via
config.
Covers custom providers / gateways proxying thinking models that the host-based
``_REASONING_ECHO_RULES`` miss. Per-active-provider: primary from ``model.reasoning_echo``, fallback
from the fallback entry's field, restored by ``restore_primary_runtime()`` — so falling back to a
strict provider still strips it.
"""
return bool(getattr(self, "_reasoning_echo_flag", False))
@staticmethod
def _read_reasoning_echo_from_config() -> bool:
"""Read ``model.reasoning_echo`` from config; False on any error."""
try:
from hermes_cli.config import load_config_readonly
return bool(
(load_config_readonly().get("model") or {}).get("reasoning_echo")
)
except Exception:
return False
def _needs_kimi_tool_reasoning(self) -> bool:
"""Return True when the current provider is Kimi / Moonshot thinking mode (requires
``reasoning_content`` echo).
Host-driven, not model-name-driven: aggregators re-exporting Kimi reject the echo (#17400). Rule
table: ``message_sanitization.reasoning_echo_family``.
"""
from agent.message_sanitization import matches_reasoning_echo_family
return matches_reasoning_echo_family(
"kimi", self.provider, None, self.base_url
)
def _needs_deepseek_tool_reasoning(self) -> bool:
"""Return True when the current provider is DeepSeek thinking mode (requires ``reasoning_content``
echo).
Omitting the echo on replayed assistant tool-call turns is an HTTP 400 (#15250). Rule table:
``message_sanitization.reasoning_echo_family``.
"""
from agent.message_sanitization import matches_reasoning_echo_family
return matches_reasoning_echo_family(
"deepseek", (self.provider or "").lower(), self.model, self.base_url
)
def _needs_mimo_tool_reasoning(self) -> bool:
"""Return True when the current provider is Xiaomi MiMo thinking mode (requires ``reasoning_content``
echo).
Rule table: ``message_sanitization.reasoning_echo_family``.
"""
from agent.message_sanitization import matches_reasoning_echo_family
return matches_reasoning_echo_family(
"mimo", (self.provider or "").lower(), self.model, self.base_url
)
_copy_reasoning_content_for_api = _forward("agent.agent_runtime_helpers", "copy_reasoning_content_for_api")
_reapply_reasoning_echo_for_provider = _forward("agent.agent_runtime_helpers", "reapply_reasoning_echo_for_provider")
@staticmethod
def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: "str | None" = None) -> dict:
"""Strip Codex Responses fields (call_id, response_item_id, extra_content) from tool_calls for strict
providers.
Strict Chat Completions APIs (Mistral, Fireworks) 400/422 on unknown fields. ``extra_content`` (Gemini
thought_signature) is kept only when the outgoing model is Gemini-family (it 400s without it). Builds
new dicts so the internal history retains the Codex fields for a later fallback.
"""
tool_calls = api_msg.get("tool_calls")
if not isinstance(tool_calls, list):
return api_msg
from agent.transports.chat_completions import _model_consumes_thought_signature
_STRIP_KEYS = {"call_id", "response_item_id"}
if not _model_consumes_thought_signature(model):
_STRIP_KEYS = _STRIP_KEYS | {"extra_content"}
api_msg["tool_calls"] = [
{k: v for k, v in tc.items() if k not in _STRIP_KEYS}
if isinstance(tc, dict) else tc
for tc in tool_calls
]
return api_msg
_sanitize_tool_call_arguments = _forward_static("agent.agent_runtime_helpers", "sanitize_tool_call_arguments")
def _should_sanitize_tool_calls(self) -> bool:
"""Determine if tool_calls need sanitization (True for every non-Codex API).
Codex Responses fields (call_id, response_item_id) are not Chat Completions schema and 400 elsewhere.
"""
return self.api_mode != "codex_responses"