mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 22:20:30 +00:00
Merge master into stage-5155 (catch up to v0.51.728)
This commit is contained in:
+42
-3
@@ -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
@@ -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
@@ -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
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user