Merge master into stage-5155 (catch up to v0.51.728)

This commit is contained in:
nesquena-hermes
2026-06-28 23:25:57 +00:00
12 changed files with 1301 additions and 40 deletions
+42 -3
View File
@@ -17,6 +17,7 @@ import logging
import os
import queue
import re
import socket
import sys
import threading
import time
@@ -7433,6 +7434,15 @@ _GATEWAY_CAPS_LOCK = threading.Lock()
_GATEWAY_CAPS_TTL_S: float = 60.0
def _gateway_caps_probe_timed_out(exc: BaseException) -> bool:
"""Keep slow capability probes on the legacy reachable-but-unsupported path."""
reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
if isinstance(reason, (TimeoutError, socket.timeout)):
return True
reason_text = str(reason).lower()
return "timed out" in reason_text or "timeout" in reason_text
def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
"""Return cached gateway capability flags, probing /v1/capabilities if stale."""
base_url = str(base_url or "").rstrip("/")
@@ -7443,21 +7453,40 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
cached = _GATEWAY_CAPS_CACHE.get(cache_key)
if cached and now - cached.get("fetched_at", 0) < _GATEWAY_CAPS_TTL_S:
return cached
caps = {"approval_events": False, "run_approval_response": False, "fetched_at": 0.0}
caps = {
"approval_events": False,
"run_approval_response": False,
"capabilities_reachable": False,
"probe_error": None,
"fetched_at": 0.0,
}
try:
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(f"{base_url}/v1/capabilities", headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
caps["capabilities_reachable"] = True
body = json.loads(resp.read(65536))
features = body.get("features") if isinstance(body, dict) else {}
if not isinstance(features, dict):
features = {}
caps["approval_events"] = bool(features.get("approval_events"))
caps["run_approval_response"] = bool(features.get("run_approval_response"))
except Exception:
pass
except urllib.error.HTTPError as exc:
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except urllib.error.URLError as exc:
if _gateway_caps_probe_timed_out(exc):
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except (TimeoutError, socket.timeout) as exc:
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except OSError as exc:
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except Exception as exc:
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
with _GATEWAY_CAPS_LOCK:
current = _GATEWAY_CAPS_CACHE.get(cache_key)
if current and current.get("fetched_at", 0) > probe_started_at:
@@ -7467,6 +7496,16 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
return caps
def gateway_approval_unavailable_reason(base_url: str, api_key: str = "") -> str | None:
"""Return why approval support is unavailable, if it is unavailable."""
caps = get_gateway_caps(base_url, api_key)
if bool(caps.get("approval_events") and caps.get("run_approval_response")):
return None
if not caps.get("capabilities_reachable"):
return "unreachable"
return "unsupported"
def gateway_supports_approval(base_url: str, api_key: str = "") -> bool:
"""True only when the gateway advertises both approval_events and run_approval_response."""
caps = get_gateway_caps(base_url, api_key)
+10 -3
View File
@@ -20,6 +20,7 @@ from api.config import (
STREAM_REASONING_TEXT,
_get_session_agent_lock,
coerce_reasoning_effort_for_model,
gateway_approval_unavailable_reason,
gateway_supports_approval,
register_active_run,
unregister_active_run,
@@ -653,13 +654,19 @@ def _run_gateway_chat_streaming(
else:
# Legacy gateway path: emit unsupported approval notice once per session,
# but only when the gateway genuinely lacks approval capability.
if not gateway_supports_approval(base_url, api_key):
approval_reason = gateway_approval_unavailable_reason(base_url, api_key)
if approval_reason is not None:
if not hasattr(s, "_approval_notice_emitted"):
s._approval_notice_emitted = False
if not s._approval_notice_emitted:
approval_message = "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
approval_type = "approval_gateway_unsupported"
if approval_reason == "unreachable":
approval_type = "approval_gateway_offline"
approval_message = "Gateway connection failed. Check that the connected Hermes gateway is running and reachable."
put_gateway_event("warning", {
"type": "approval_gateway_unsupported",
"message": "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.",
"type": approval_type,
"message": approval_message,
})
s._approval_notice_emitted = True
+47 -9
View File
@@ -5215,31 +5215,52 @@ def _read_profile_model_config(
) -> tuple[str | None, str | None]:
"""Read model.provider and model.default from the session's profile config.
Returns (profile_provider, profile_default_model). Both are None when
the session has no profile, the profile config is unreadable, or an
explicit ``requested_provider`` is already set (profile should not
override explicit selections).
Returns (profile_provider, profile_default_model). Both are None when the
session has no profile or the profile config is unreadable.
When the session already has an explicit ``requested_provider``, the profile
``model.provider`` is not returned (first tuple element is None) so profile
does not override the session provider. ``profile_default_model`` is still
returned for suffix repair (#5127) only when the profile's configured
provider matches ``requested_provider`` after normalization.
"""
if _clean_session_model_provider(requested_provider) or not getattr(session, "profile", None):
if not getattr(session, "profile", None):
return None, None
try:
from api.profiles import get_hermes_home_for_profile
_profile_home = get_hermes_home_for_profile(session.profile)
_profile_cfg_path = os.path.join(str(_profile_home), "config.yaml")
if not os.path.isfile(_profile_cfg_path):
return None, None
import yaml
with open(_profile_cfg_path, encoding="utf-8") as _f:
_pcfg = yaml.safe_load(_f) or {}
if not isinstance(_pcfg, dict):
return None, None
_provider = (_pcfg.get("model", {}).get("provider") or "").strip() or None
_default = (_pcfg.get("model", {}).get("default") or "").strip() or None
return _provider, _default
_model_cfg = _pcfg.get("model") or {}
if not isinstance(_model_cfg, dict):
return None, None
_provider = (_model_cfg.get("provider") or "").strip() or None
_default = (_model_cfg.get("default") or "").strip() or None
except Exception:
logger.warning("profile provider read failed for %r", getattr(session, "profile", None), exc_info=True)
logger.warning(
"profile provider read failed for %r",
getattr(session, "profile", None),
exc_info=True,
)
return None, None
_requested = _clean_session_model_provider(requested_provider)
if _requested:
_profile_prov = _clean_session_model_provider(_provider)
if _profile_prov != _requested:
return None, None
return None, _default
return _provider, _default
def _resolve_compatible_session_model_state(
model_id: str | None,
@@ -5303,6 +5324,23 @@ def _resolve_compatible_session_model_state(
and model_prefix == "openai"
)
if not explicit_provider and not stale_codex_openai_slash_id:
_profile_default = str(profile_default_model or "").strip()
_profile_prov = _clean_session_model_provider(profile_provider)
_providers_match_for_repair = (
_profile_prov is None or _profile_prov == requested_provider
)
if (
_profile_default
and "/" in _profile_default
and "/" not in model
and _profile_default.rsplit("/", 1)[-1] == model
and _providers_match_for_repair
and (
requested_provider == "custom"
or str(requested_provider).startswith("custom:")
)
):
return _profile_default, requested_provider, True
return model, requested_provider, False
# Default (human chat/start) path calls get_available_models() with NO
+148 -17
View File
@@ -1100,13 +1100,61 @@ def _cancelled_turn_hint(agent_name: str | None = None) -> str:
return f'The run was cancelled by the user before {name} finished. No provider failure occurred.'
def _provider_error_probe_text(value) -> tuple[str, int | None]:
"""Flatten structured provider-error payloads into searchable text."""
_texts: list[str] = []
_status_code: int | None = None
_seen: set[int] = set()
def _walk(node):
nonlocal _status_code
if node is None:
return
if isinstance(node, (dict, list, tuple, set)):
_node_id = id(node)
if _node_id in _seen:
return
_seen.add(_node_id)
if isinstance(node, dict):
for _key in ('type', 'code', 'message', 'detail', 'details', 'name', 'status', 'status_code'):
_val = node.get(_key)
if _val is None:
continue
if _key in ('status', 'status_code') and _status_code is None:
try:
_status_code = int(_val)
except Exception:
pass
_texts.append(str(_val))
for _key, _val in node.items():
if _key in ('type', 'code', 'message', 'detail', 'details', 'name', 'status', 'status_code'):
continue
_walk(_val)
return
if isinstance(node, (list, tuple, set)):
for _item in node:
_walk(_item)
return
_texts.append(str(node))
_walk(value)
return ' '.join(t for t in _texts if t).strip(), _status_code
def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict:
"""Classify provider/agent failure text for WebUI apperror UX.
Keep this string-based until hermes-agent exposes stable structured
provider error classes for Codex OAuth plan limits.
"""
err_str = str(err_str or '')
_probe_text, _probe_status_code = _provider_error_probe_text(err_str)
if exc is not None:
_exc_probe_text, _exc_status_code = _provider_error_probe_text(exc)
if _exc_probe_text:
_probe_text = f"{_probe_text} {_exc_probe_text}".strip()
if _probe_status_code is None:
_probe_status_code = _exc_status_code
err_str = str(_probe_text or err_str or '')
_err_lower = err_str.lower()
_exc_name = type(exc).__name__ if exc is not None else ''
_is_cancelled = (
@@ -1147,6 +1195,8 @@ def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = F
_is_quota = _is_quota_error_text(err_str)
_is_auth = (
not _is_quota and (
_probe_status_code == 401
or
'401' in err_str
or (exc is not None and 'AuthenticationError' in _exc_name)
or 'authentication' in _err_lower
@@ -4890,8 +4940,22 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
previous_user_tail,
previous_context=previous_context,
)
candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates)
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
current_user_in_candidates = any(
_message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text)
for m in candidates
)
assistant_or_tool_only_candidates = bool(candidates) and all(
_is_context_compression_marker(m)
or (
isinstance(m, dict)
and m.get('role') in ('assistant', 'tool')
)
for m in candidates
)
if not (assistant_or_tool_only_candidates and not current_user_in_candidates):
candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates)
else:
current_user_idx = _find_current_user_turn(result_messages, msg_text)
turn_candidates = result_messages[current_user_idx:] if current_user_idx is not None else []
@@ -5056,6 +5120,64 @@ def _session_lacks_final_assistant_answer(messages) -> bool:
return True
def _merged_transcript_lacks_final_assistant_answer(
previous_display,
previous_context,
result_messages,
msg_text,
source: str = "webui",
drop_replayed_assistant: bool = False,
) -> bool:
"""Return True when the current turn still lacks a final assistant answer."""
previous_display = list(previous_display or [])
merged_messages = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
_restore_reasoning_metadata(previous_display, result_messages),
msg_text,
source=source,
)
current_user_idx = _find_current_user_turn(merged_messages, msg_text)
if current_user_idx is None or current_user_idx < len(previous_display):
# The active turn lives after the durable transcript boundary. If the
# merged display only exposes an older user row, materialize the pending
# prompt so a replayed assistant row cannot satisfy the wrong turn.
pending_user = {
'role': 'user',
'content': msg_text,
}
if source and source != 'webui':
pending_user['_source'] = source
merged_messages.append(pending_user)
current_user_idx = len(merged_messages) - 1
current_user_key = _message_identity(merged_messages[current_user_idx])
filtered_messages = merged_messages[:current_user_idx + 1]
if drop_replayed_assistant:
prior_id_set = {
_message_identity(msg)
for msg in merged_messages[:current_user_idx]
if isinstance(msg, dict)
}
for msg in merged_messages[current_user_idx + 1:]:
if not isinstance(msg, dict):
filtered_messages.append(msg)
continue
if msg.get('role') == 'assistant':
key = _message_identity(msg)
if key is not None and key in prior_id_set:
continue
filtered_messages.append(msg)
else:
filtered_messages.extend(merged_messages[current_user_idx + 1:])
if current_user_key is not None:
filtered_messages = [
msg for msg in filtered_messages
if _message_identity(msg) != current_user_key or msg is merged_messages[current_user_idx]
]
return _session_lacks_final_assistant_answer(filtered_messages)
def _agent_result_terminal_failure(result) -> bool:
"""Return True for agent results that must not be finalized as done."""
if not isinstance(result, dict):
@@ -7973,15 +8095,32 @@ def _run_agent_streaming(
_previous_context_messages,
msg_text,
)
_last_err = getattr(agent, '_last_error', None) or result.get('error') or ''
_classification = _classify_provider_error(
str(_last_err) if _last_err else '',
_last_err,
silent_failure=not bool(_last_err),
)
_is_quota = _classification['type'] == 'quota_exhausted'
_is_auth = _classification['type'] == 'auth_mismatch'
_drop_replayed_assistant = (
_agent_result_terminal_failure(result)
or bool(getattr(agent, '_last_error', None))
or ('error' in result and result.get('error') is not None)
)
_saved_transcript_lacks_final_answer = _merged_transcript_lacks_final_assistant_answer(
_previous_messages,
_previous_context_messages,
_all_result_messages,
msg_text,
source=getattr(s, 'pending_user_source', None) or 'webui',
drop_replayed_assistant=_drop_replayed_assistant,
)
_terminal_failure = (
_agent_result_terminal_failure(result)
or (
_tool_limit_reached
and _session_lacks_final_assistant_answer(_all_result_messages)
)
or (
not _token_sent
and _session_lacks_final_assistant_answer(_all_result_messages)
_saved_transcript_lacks_final_answer
and _classification['type'] not in {'cancelled', 'interrupted'}
)
)
if _terminal_failure:
@@ -8007,15 +8146,7 @@ def _run_agent_streaming(
logger.debug("Failed to append cancelled turn journal event", exc_info=True)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
_last_err = getattr(agent, '_last_error', None) or result.get('error') or ''
_err_str = str(_last_err) if _last_err else ''
_classification = _classify_provider_error(
_err_str,
_last_err,
silent_failure=not bool(_err_str),
)
_is_quota = _classification['type'] == 'quota_exhausted'
_is_auth = _classification['type'] == 'auth_mismatch'
if _is_quota:
_err_label = _classification['label']
_err_type = _classification['type']