refactor(tui): server.py — _parse_model_config/_is_routable_provider/_env_model_seed helpers, alias tables for service tier + tool progress, compact runtime-override docstrings
This commit is contained in:
+153
-303
@@ -1498,21 +1498,21 @@ def _clear_pending(sid: str | None = None) -> None:
|
||||
# ── Agent factory ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _env_model_seed() -> str:
|
||||
"""The launch-scoped model seed (``hermes --tui -m``, hosted provisioning); "" when unset."""
|
||||
return (os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "")).strip()
|
||||
|
||||
|
||||
def _resolve_model() -> str:
|
||||
env = (
|
||||
os.environ.get("HERMES_MODEL", "")
|
||||
or os.environ.get("HERMES_INFERENCE_MODEL", "")
|
||||
).strip()
|
||||
if env:
|
||||
if env := _env_model_seed():
|
||||
return env
|
||||
m = _load_cfg().get("model", "")
|
||||
if isinstance(m, dict):
|
||||
return str(m.get("default", "") or "").strip()
|
||||
if isinstance(m, str) and m:
|
||||
return m.strip()
|
||||
# No env seed and no config preference: fall back to the cost-safe silent
|
||||
# default (catalog-labeled, cache-only read), never an expensive Anthropic
|
||||
# flagship the user didn't pick.
|
||||
# No env seed and no config preference: the cost-safe silent default (catalog-labeled,
|
||||
# cache-only read), never an expensive flagship the user didn't pick.
|
||||
try:
|
||||
from hermes_cli.models import get_preferred_silent_default_model
|
||||
return get_preferred_silent_default_model()
|
||||
@@ -1521,33 +1521,17 @@ def _resolve_model() -> str:
|
||||
|
||||
|
||||
def _resolve_session_platform() -> str:
|
||||
"""Resolve the platform tag for a tui_gateway-routed session.
|
||||
|
||||
Stamping the desktop chat panel ``platform="tui"`` makes the agent suggest
|
||||
TUI-only slash commands to chat-panel users.
|
||||
* ``HERMES_DESKTOP=1`` with ``HERMES_DESKTOP_TERMINAL`` unset → "desktop"
|
||||
* ``HERMES_DESKTOP_TERMINAL=1`` → "tui" (embedded terminal pane; the tui
|
||||
hint's clarifier in system_prompt.py describes the embedding)
|
||||
* neither → "tui" (standalone ``hermes --tui``)
|
||||
"""
|
||||
if is_truthy_value(os.environ.get("HERMES_DESKTOP")) and not is_truthy_value(
|
||||
os.environ.get("HERMES_DESKTOP_TERMINAL")
|
||||
):
|
||||
return "desktop"
|
||||
return "tui"
|
||||
"""Platform tag for a tui_gateway-routed session: ``HERMES_DESKTOP=1`` without
|
||||
``HERMES_DESKTOP_TERMINAL`` → "desktop" (chat panel; the agent then suggests TUI-only slash
|
||||
commands), else "tui" (embedded terminal pane or standalone ``hermes --tui``)."""
|
||||
desktop = is_truthy_value(os.environ.get("HERMES_DESKTOP"))
|
||||
return "desktop" if desktop and not is_truthy_value(os.environ.get("HERMES_DESKTOP_TERMINAL")) else "tui"
|
||||
|
||||
|
||||
def _resolve_session_source(explicit: str | None) -> str:
|
||||
"""Default the session DB ``source`` field from the resolved platform.
|
||||
|
||||
A caller that explicitly passes ``source`` (e.g. a plugin session tagged
|
||||
``"telegram"``) keeps its value. Only an empty/None ``source`` falls back
|
||||
to the env-resolved platform — so env-driven resolution never silently
|
||||
rewrites a caller's intent.
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
return _resolve_session_platform()
|
||||
"""Session DB ``source``: an explicit caller value (plugin session tagged ``"telegram"``) is
|
||||
never rewritten; only empty/None falls back to the env-resolved platform."""
|
||||
return explicit or _resolve_session_platform()
|
||||
|
||||
|
||||
def _resolve_agent_platform(source: str | None) -> str:
|
||||
@@ -1555,40 +1539,24 @@ def _resolve_agent_platform(source: str | None) -> str:
|
||||
|
||||
|
||||
def _config_model_target() -> tuple[str, str]:
|
||||
"""(model, provider) currently selected by config.yaml — and ONLY config.
|
||||
"""(model, provider) selected by config.yaml — and ONLY config.
|
||||
|
||||
Unlike `_resolve_model()`, this never reads HERMES_MODEL /
|
||||
HERMES_INFERENCE_MODEL. Those env vars are a launch-scoped seed
|
||||
(`hermes --tui -m <model>`, hosted-instance provisioning); if they
|
||||
fed the per-turn sync, the seed would be replayed as a /model switch
|
||||
and persisted globally, or would pin the session so dashboard/CLI
|
||||
model changes never reach an open chat.
|
||||
Never reads HERMES_MODEL / HERMES_INFERENCE_MODEL: that launch-scoped seed fed into the
|
||||
per-turn sync would be replayed as a /model switch and persisted globally, or pin the session
|
||||
so dashboard/CLI model changes never reach an open chat. Empty model = "no preference" → no-op sync.
|
||||
"""
|
||||
cfg_model = _load_cfg().get("model")
|
||||
model = ""
|
||||
provider = ""
|
||||
if isinstance(cfg_model, dict):
|
||||
model = str(cfg_model.get("default", "") or "").strip()
|
||||
provider = str(cfg_model.get("provider") or "").strip()
|
||||
if provider.lower() == "auto":
|
||||
provider = ""
|
||||
elif isinstance(cfg_model, str):
|
||||
model = cfg_model.strip()
|
||||
# No _resolve_model() fallback: that reads the launch-scoped -m env seed,
|
||||
# which the per-turn sync would replay as a /model switch and persist
|
||||
# globally. Empty model = "config expresses no preference" → sync is a no-op.
|
||||
return model, provider
|
||||
return str(cfg_model.get("default", "") or "").strip(), "" if provider.lower() == "auto" else provider
|
||||
return (cfg_model.strip() if isinstance(cfg_model, str) else ""), ""
|
||||
|
||||
|
||||
def _resolve_startup_runtime() -> tuple[str, str | None]:
|
||||
model = _resolve_model()
|
||||
explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
|
||||
if explicit_provider:
|
||||
if explicit_provider := os.environ.get("HERMES_TUI_PROVIDER", "").strip():
|
||||
return model, explicit_provider
|
||||
explicit_model = (
|
||||
os.environ.get("HERMES_MODEL", "")
|
||||
or os.environ.get("HERMES_INFERENCE_MODEL", "")
|
||||
).strip()
|
||||
explicit_model = _env_model_seed()
|
||||
if not explicit_model:
|
||||
return model, None
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1599,34 +1567,19 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
|
||||
or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
or "auto"
|
||||
)
|
||||
detected = detect_static_provider_for_model(explicit_model, current_provider)
|
||||
if detected:
|
||||
if detected := detect_static_provider_for_model(explicit_model, current_provider):
|
||||
provider, detected_model = detected
|
||||
return detected_model, provider
|
||||
return model, None
|
||||
|
||||
|
||||
# Bare billing buckets are not routable provider identities; restoring one as a
|
||||
# session provider override breaks resume. ``openrouter`` is deliberately NOT in
|
||||
# this set (fully routable; dropping it resumed OpenRouter sessions on the wrong
|
||||
# provider) — agent_init's fail-fast gate is a different set that skips it.
|
||||
# Bare billing buckets are not routable provider identities; restoring one as a session
|
||||
# provider override breaks resume. ``openrouter`` is deliberately NOT in this set (fully
|
||||
# routable) — agent_init's fail-fast gate is a different set that skips it.
|
||||
from hermes_state import _BARE_BILLING_PROVIDERS
|
||||
|
||||
|
||||
def _overrides_have_routable_provider(overrides: dict) -> bool:
|
||||
"""Whether persisted runtime overrides still name a routable provider.
|
||||
|
||||
A session row written under a provider that has since been renamed or
|
||||
removed would otherwise fail agent init with "Unknown provider".
|
||||
Empty provider counts as NOT routable here, so the caller falls back
|
||||
to the model the user picked for this session / the configured
|
||||
default instead of restoring a provider-less snapshot override.
|
||||
"""
|
||||
provider = str(overrides.get("provider_override") or "").strip()
|
||||
if not provider:
|
||||
provider = str((overrides.get("model_override") or {}).get("provider") or "").strip()
|
||||
if not provider:
|
||||
return False
|
||||
def _is_routable_provider(provider: str) -> bool:
|
||||
try:
|
||||
from hermes_cli.runtime_provider import is_routable_provider
|
||||
return is_routable_provider(provider)
|
||||
@@ -1634,37 +1587,45 @@ def _overrides_have_routable_provider(overrides: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _overrides_have_routable_provider(overrides: dict) -> bool:
|
||||
"""Whether persisted runtime overrides still name a routable provider (renamed/removed →
|
||||
"Unknown provider" at agent init). Empty counts as NOT routable, so the caller falls back to the
|
||||
session's picked model / configured default instead of a provider-less snapshot."""
|
||||
provider = str(overrides.get("provider_override") or "").strip()
|
||||
if not provider:
|
||||
provider = str((overrides.get("model_override") or {}).get("provider") or "").strip()
|
||||
return bool(provider) and _is_routable_provider(provider)
|
||||
|
||||
|
||||
def _parse_model_config(raw, *, quiet: bool = False) -> dict:
|
||||
"""A row's ``model_config`` (dict or JSON text) as a dict; ``{}`` when absent/invalid."""
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
if not quiet:
|
||||
raise
|
||||
logger.debug("failed to parse stored session model_config", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
||||
"""Return runtime fields persisted with a stored session.
|
||||
"""Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``).
|
||||
|
||||
``session.resume`` is session-scoped: reopening an older chat must restore
|
||||
the model/provider/reasoning state that chat actually used, not the global
|
||||
model most recently selected elsewhere. The row stores the model directly,
|
||||
the billing provider in ``billing_provider``, and richer knobs in JSON
|
||||
``model_config``.
|
||||
|
||||
Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member
|
||||
profile's CURRENT config — restoring a stale provider pin is what left room
|
||||
bots / bot DMs failing ("out of Nous credits") after the profile switched.
|
||||
Signals, in order: the explicit ``room_plumbing`` / ``follow_profile_config``
|
||||
markers persisted by session.create consumers; the legacy hidden +
|
||||
"Group:" title shape (older desktop builds sent no marker); and the title
|
||||
exactly "Bot Chat" (the plugin's own identity rule for the forever-DM,
|
||||
UNIQUE(title) makes it exact; pre-policy rows may be visible or hidden).
|
||||
``session.resume`` is session-scoped: reopening an older chat restores the model/provider/
|
||||
reasoning THAT chat used, not the global model most recently selected elsewhere.
|
||||
Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member profile's CURRENT
|
||||
config (a stale provider pin left room bots failing "out of Nous credits" after a profile
|
||||
switch). Signals, in order: explicit ``room_plumbing`` / ``follow_profile_config`` markers;
|
||||
the legacy hidden + "Group:" title shape; the title exactly "Bot Chat" (UNIQUE(title)).
|
||||
"""
|
||||
if not row:
|
||||
return {}
|
||||
raw_config = row.get("model_config")
|
||||
model_config: dict = {}
|
||||
if isinstance(raw_config, dict):
|
||||
model_config = raw_config
|
||||
elif isinstance(raw_config, str) and raw_config.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_config)
|
||||
if isinstance(parsed, dict):
|
||||
model_config = parsed
|
||||
except Exception:
|
||||
logger.debug("failed to parse stored session model_config", exc_info=True)
|
||||
model_config = _parse_model_config(row.get("model_config"), quiet=True)
|
||||
_row_title = str(row.get("title") or "").strip()
|
||||
if (
|
||||
model_config.get("room_plumbing")
|
||||
@@ -1675,64 +1636,45 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
||||
return {}
|
||||
overrides: dict = {}
|
||||
model = str(row.get("model") or model_config.get("model") or "").strip()
|
||||
# ``billing_provider`` is only the billing bucket — for a custom endpoint it
|
||||
# is the bare class ``"custom"``, which agent_init treats as non-routable, so
|
||||
# restoring it as the provider override fails resume with "No LLM provider
|
||||
# configured". Only restore an explicit provider; otherwise leave it unset
|
||||
# so resume falls back to the configured default (CLI parity).
|
||||
explicit_provider = str(model_config.get("provider") or "").strip()
|
||||
billing_provider = str(
|
||||
model_config.get("billing_provider") or row.get("billing_provider") or ""
|
||||
).strip()
|
||||
provider = explicit_provider
|
||||
# ``billing_provider`` is only the billing bucket — for a custom endpoint the bare class
|
||||
# "custom", which agent_init treats as non-routable ("No LLM provider configured" on resume).
|
||||
# Only restore an explicit provider; otherwise resume falls back to the configured default.
|
||||
provider = str(model_config.get("provider") or "").strip()
|
||||
billing_provider = str(model_config.get("billing_provider") or row.get("billing_provider") or "").strip()
|
||||
if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
|
||||
provider = billing_provider
|
||||
base_url = str(model_config.get("base_url") or "").strip()
|
||||
api_mode = str(model_config.get("api_mode") or "").strip()
|
||||
reasoning_config = model_config.get("reasoning_config")
|
||||
service_tier = str(model_config.get("service_tier") or "").strip()
|
||||
|
||||
# Heal a stale/expired provider name persisted by an older build (a renamed
|
||||
# or removed custom provider would fail agent init with "Unknown provider").
|
||||
# Recover the durable ``custom:<name>`` key from the stored base_url, then
|
||||
# from the entry serving the stored model; when nothing names a real entry,
|
||||
# drop the provider so resume falls back to the configured default.
|
||||
if provider:
|
||||
# Heal a stale provider name persisted by an older build (renamed/removed custom provider →
|
||||
# "Unknown provider"): recover the durable ``custom:<name>`` key from the stored base_url, then
|
||||
# from the entry serving the stored model; when nothing names a real entry, drop the provider.
|
||||
if provider and not _is_routable_provider(provider):
|
||||
healed = None
|
||||
try:
|
||||
from hermes_cli.runtime_provider import is_routable_provider
|
||||
routable = is_routable_provider(provider)
|
||||
from hermes_cli.runtime_provider import canonical_custom_identity
|
||||
healed = canonical_custom_identity(base_url=base_url or None, model=model or None)
|
||||
except Exception:
|
||||
routable = False
|
||||
if not routable:
|
||||
healed = None
|
||||
try:
|
||||
from hermes_cli.runtime_provider import canonical_custom_identity
|
||||
healed = canonical_custom_identity(base_url=base_url or None, model=model or None)
|
||||
except Exception:
|
||||
logger.debug("custom provider identity recovery failed", exc_info=True)
|
||||
if healed:
|
||||
logger.info("healed stale session provider %r to %r", provider, healed)
|
||||
provider = healed
|
||||
# The healed identity owns a registered endpoint; the snapshot's
|
||||
# base_url must not override the registry URL.
|
||||
base_url = ""
|
||||
else:
|
||||
provider = ""
|
||||
logger.debug("custom provider identity recovery failed", exc_info=True)
|
||||
if healed:
|
||||
logger.info("healed stale session provider %r to %r", provider, healed)
|
||||
provider = healed
|
||||
base_url = "" # the healed identity owns a registered endpoint; the snapshot URL must not override it
|
||||
else:
|
||||
provider = ""
|
||||
if model:
|
||||
# Same dict-shaped override live /model switches use, so a DB-restored
|
||||
# session keeps custom endpoint metadata across resume and rebuilds
|
||||
# (/new). Raw api_key is deliberately never persisted or restored here.
|
||||
# Same dict-shaped override live /model switches use, so a DB-restored session keeps custom
|
||||
# endpoint metadata across resume and rebuilds (/new). Raw api_key is never persisted/restored.
|
||||
overrides["model_override"] = {
|
||||
"model": model, "provider": provider or None, "base_url": base_url or None,
|
||||
"api_mode": api_mode or None,
|
||||
"model": model, "provider": provider or None, "base_url": base_url or None, "api_mode": api_mode or None,
|
||||
}
|
||||
if provider:
|
||||
overrides["provider_override"] = provider
|
||||
if isinstance(reasoning_config, dict):
|
||||
overrides["reasoning_config_override"] = reasoning_config
|
||||
if service_tier.lower() == "normal":
|
||||
# None means "inherit the profile" at _make_agent; "" is a real override
|
||||
# meaning "do not request a priority service tier".
|
||||
# None = "inherit the profile" at _make_agent; "" = real override "no priority tier".
|
||||
overrides["service_tier_override"] = ""
|
||||
elif service_tier:
|
||||
overrides["service_tier_override"] = service_tier
|
||||
@@ -1740,30 +1682,21 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
|
||||
|
||||
|
||||
def _runtime_model_config(agent, existing: dict | None = None) -> dict:
|
||||
"""Merge the agent's CURRENT runtime identity onto an existing config.
|
||||
"""Merge the agent's CURRENT runtime identity onto the row's persisted ``model_config``.
|
||||
|
||||
``existing`` is the row's previously-persisted ``model_config`` JSON (may
|
||||
be absent on first write). The returned dict must mirror the agent's live
|
||||
state: falsy agent attributes DELETE the corresponding key rather than
|
||||
merely omit the write, so a stale value from an earlier session state can
|
||||
never survive into the merged config. Keeping stale values here is what
|
||||
desynced the ``sessions.model`` column (fresh) from ``model_config``
|
||||
(stale provider/endpoint): ``_persist_live_session_runtime`` writes the
|
||||
model column separately, and on resume ``_stored_session_runtime_overrides``
|
||||
reads provider/endpoint from this JSON — so a stale provider would silently
|
||||
route the resumed chat to the wrong endpoint while the model column claimed
|
||||
the new one.
|
||||
Falsy agent attributes DELETE the key rather than skip the write, so a stale value can never
|
||||
survive the merge: ``_persist_live_session_runtime`` writes the model column separately and
|
||||
resume reads provider/endpoint from this JSON — a stale provider would silently route the
|
||||
resumed chat to the wrong endpoint while the model column claimed the new one.
|
||||
"""
|
||||
config = dict(existing or {})
|
||||
model = str(getattr(agent, "model", "") or "").strip()
|
||||
provider = str(getattr(agent, "provider", "") or "").strip()
|
||||
base_url = str(getattr(agent, "base_url", "") or "").strip()
|
||||
if provider.lower() == "custom":
|
||||
# ``agent.provider`` resolves every named custom entry to the literal
|
||||
# "custom", which loses the entry identity (api_key is never persisted,
|
||||
# so resume couldn't re-resolve credentials). Recover the canonical
|
||||
# ``custom:<name>`` key from the endpoint URL, else from the configured
|
||||
# provider (the no-base_url case that routed to OpenRouter with no key).
|
||||
# ``agent.provider`` resolves every named custom entry to the literal "custom", losing the
|
||||
# entry identity (api_key is never persisted, so resume couldn't re-resolve credentials).
|
||||
# Recover the canonical ``custom:<name>`` key from the endpoint URL, else the configured provider.
|
||||
try:
|
||||
from hermes_cli.runtime_provider import canonical_custom_identity
|
||||
provider = canonical_custom_identity(base_url=base_url, model=model or None) or provider
|
||||
@@ -1771,9 +1704,7 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:
|
||||
logger.debug("custom provider identity lookup failed", exc_info=True)
|
||||
reasoning_config = getattr(agent, "reasoning_config", None)
|
||||
live = {
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"model": model, "provider": provider, "base_url": base_url,
|
||||
"api_mode": str(getattr(agent, "api_mode", "") or "").strip(),
|
||||
# An empty dict is still a real (present) reasoning config.
|
||||
"reasoning_config": reasoning_config if isinstance(reasoning_config, dict) else None,
|
||||
@@ -1800,20 +1731,11 @@ def _persist_live_session_runtime(session: dict | None) -> None:
|
||||
return
|
||||
try:
|
||||
row = db.get_session(session_key) or {}
|
||||
raw_config = row.get("model_config")
|
||||
existing_config = {}
|
||||
if isinstance(raw_config, dict):
|
||||
existing_config = raw_config
|
||||
elif isinstance(raw_config, str) and raw_config.strip():
|
||||
parsed = json.loads(raw_config)
|
||||
if isinstance(parsed, dict):
|
||||
existing_config = parsed
|
||||
model_config = _runtime_model_config(agent, existing_config)
|
||||
model_config = _runtime_model_config(agent, _parse_model_config(row.get("model_config")))
|
||||
create_service_tier_override = session.get("create_service_tier_override")
|
||||
if create_service_tier_override is not None:
|
||||
# _runtime_model_config sees agent.service_tier=None for explicit
|
||||
# normal and would otherwise erase the distinction on every live
|
||||
# metadata persist.
|
||||
# _runtime_model_config sees agent.service_tier=None for explicit normal and would
|
||||
# otherwise erase the distinction on every live metadata persist.
|
||||
model_config["service_tier"] = create_service_tier_override or "normal"
|
||||
model = str(getattr(agent, "model", "") or "").strip()
|
||||
if hasattr(db, "update_session_meta"):
|
||||
@@ -1836,13 +1758,11 @@ def _persist_live_session_system_prompt(session: dict | None) -> None:
|
||||
if db is None or not hasattr(db, "update_system_prompt"):
|
||||
return
|
||||
|
||||
# Re-bind HERMES_HOME to the session's profile: the build's finally already
|
||||
# reset it, and the rebuilt prompt would use the root profile's SOUL.md/skills.
|
||||
# Re-bind HERMES_HOME to the session's profile (the build's finally reset it; the rebuilt
|
||||
# prompt would use the root profile's SOUL.md/skills) and the session context (on the RPC
|
||||
# thread _SESSION_CWD is unset, so the prompt would persist the process TERMINAL_CWD).
|
||||
profile_home = session.get("profile_home")
|
||||
home_token = (set_hermes_home_override(profile_home) if profile_home else None)
|
||||
# Bind the session context too: on the RPC dispatcher thread _SESSION_CWD is
|
||||
# unset, so resolve_agent_cwd() falls back to the process TERMINAL_CWD and
|
||||
# the rebuilt prompt persists the wrong cwd (later turns reuse the bytes).
|
||||
home_token = set_hermes_home_override(profile_home) if profile_home else None
|
||||
session_tokens = _set_session_context(session_key, cwd=_session_cwd(session))
|
||||
try:
|
||||
prompt = agent._build_system_prompt(None)
|
||||
@@ -1873,28 +1793,18 @@ def _is_model_switch_marker(entry: Any) -> bool:
|
||||
|
||||
|
||||
def _is_pivot_marker(entry: Any) -> bool:
|
||||
"""Whether a history entry is a marker the gateway splices in mid-turn.
|
||||
|
||||
Model switches and personality changes both inject a ``role=user`` pivot
|
||||
into the live history from the RPC thread while a turn may be running, so
|
||||
either one can be the sole reason turn-start and current history differ.
|
||||
Only the model-switch marker is self-replacing, which is why the dedup in
|
||||
:func:`_append_model_switch_marker` stays narrower than this.
|
||||
"""
|
||||
if _is_model_switch_marker(entry):
|
||||
return True
|
||||
return isinstance(entry, dict) and entry.get("display_kind") == "personality_switch"
|
||||
"""Whether a history entry is a ``role=user`` pivot the gateway splices in mid-turn (model switch
|
||||
or personality change) — either can be the sole reason turn-start and current history differ.
|
||||
Only the model-switch marker is self-replacing, so :func:`_append_model_switch_marker` dedups narrower."""
|
||||
return _is_model_switch_marker(entry) or (isinstance(entry, dict) and entry.get("display_kind") == "personality_switch")
|
||||
|
||||
|
||||
def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None:
|
||||
"""Record a real system-history pivot after a live model switch.
|
||||
|
||||
Only the most recent marker is kept: each new switch first strips any
|
||||
prior model-switch markers from the live history, so N switches leave one
|
||||
marker (naming the active model), not N stale ones accumulating tokens on
|
||||
every subsequent API call (#65891). The in-memory history is the payload
|
||||
re-sent each turn; the dedup is self-healing across resumes because the
|
||||
next switch collapses whatever markers a reload brought back.
|
||||
Only the newest marker is kept: each switch strips prior model-switch markers first, so N
|
||||
switches leave one marker rather than N stale ones re-sent on every API call; self-healing
|
||||
across resumes because the next switch collapses whatever a reload brought back.
|
||||
"""
|
||||
if not session:
|
||||
return
|
||||
@@ -1907,44 +1817,31 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s
|
||||
f"{model}{provider_part}. From this point forward, use this runtime "
|
||||
"metadata when answering questions about what model/provider is active.]"
|
||||
)
|
||||
# A user message, not system: strict OpenAI-compatible providers (vLLM,
|
||||
# Qwen) reject non-leading system messages.
|
||||
# A user message, not system: strict OpenAI-compatible providers (vLLM, Qwen) reject
|
||||
# non-leading system messages.
|
||||
entry = {"role": "user", "content": marker, "display_kind": "model_switch"}
|
||||
|
||||
def _replace_markers() -> None:
|
||||
with session.get("history_lock") or contextlib.nullcontext():
|
||||
history = session.setdefault("history", [])
|
||||
# Drop any earlier markers in place before appending the new one.
|
||||
history[:] = [h for h in history if not _is_model_switch_marker(h)]
|
||||
history.append(entry)
|
||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||
lock = session.get("history_lock")
|
||||
if lock is not None:
|
||||
with lock:
|
||||
_replace_markers()
|
||||
else:
|
||||
_replace_markers()
|
||||
try:
|
||||
agent = session.get("agent")
|
||||
db = getattr(agent, "_session_db", None) if agent is not None else None
|
||||
if db is not None:
|
||||
db.append_message(
|
||||
session_id=session_key, role="user", content=marker, display_kind="model_switch",
|
||||
)
|
||||
return
|
||||
_ensure_session_db_row(session)
|
||||
with _session_db(session) as scoped_db:
|
||||
if scoped_db is not None:
|
||||
scoped_db.append_message(
|
||||
session_id=session_key, role="user", content=marker,
|
||||
display_kind="model_switch",
|
||||
)
|
||||
if db is None:
|
||||
_ensure_session_db_row(session)
|
||||
with _session_db(session) as db:
|
||||
if db is not None:
|
||||
db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch")
|
||||
else:
|
||||
db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch")
|
||||
except Exception:
|
||||
logger.debug("failed to persist model switch marker", exc_info=True)
|
||||
|
||||
|
||||
def _write_config_key(key_path: str, value):
|
||||
# Write-back round-trip: raw read is mandatory — saving the managed-
|
||||
# overlaid / env-expanded view would persist those values into the file.
|
||||
# Write-back round-trip: raw read is mandatory — saving the managed-overlaid / env-expanded
|
||||
# view would persist those values into the file.
|
||||
cfg = _load_cfg_raw()
|
||||
current = cfg
|
||||
keys = key_path.split(".")
|
||||
@@ -1959,26 +1856,18 @@ def _write_config_key(key_path: str, value):
|
||||
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
|
||||
_APPROVAL_MODES = frozenset({"manual", "smart", "off"})
|
||||
|
||||
# Appearance switches the renderer owns but the AGENT must see (each gates a
|
||||
# tool's `check_fn`), so the toggle must reach whichever gateway the app talks
|
||||
# to. `config.set` answers 4002 for unlisted keys — a mirrored switch missing
|
||||
# here writes nothing and its tool stays dark. Add renderer mirrors here too.
|
||||
_DISPLAY_TOGGLE_KEYS = frozenset(
|
||||
{"display.message_reactions", "display.in_app_tips", "display.in_app_tours"}
|
||||
)
|
||||
# Appearance switches the renderer owns but the AGENT must see (each gates a tool's `check_fn`),
|
||||
# so the toggle must reach whichever gateway the app talks to. `config.set` answers 4002 for
|
||||
# unlisted keys — a mirrored switch missing here writes nothing and its tool stays dark.
|
||||
_DISPLAY_TOGGLE_KEYS = frozenset({"display.message_reactions", "display.in_app_tips", "display.in_app_tours"})
|
||||
_BOOL_WORDS = {
|
||||
"1": True, "on": True, "true": True, "yes": True, "0": False, "off": False, "false": False,
|
||||
"no": False,
|
||||
"1": True, "on": True, "true": True, "yes": True, "0": False, "off": False, "false": False, "no": False,
|
||||
}
|
||||
|
||||
|
||||
def _load_approval_mode() -> str:
|
||||
"""Resolve the effective ``approvals.mode`` for the TUI surface.
|
||||
|
||||
Delegates to ``tools.approval._get_approval_mode`` so the mode cannot drift
|
||||
from the approval gate's own view (a local raw-config re-read missed the
|
||||
managed overlay and ``${VAR}`` expansion).
|
||||
"""
|
||||
"""Effective ``approvals.mode`` via ``tools.approval._get_approval_mode`` so it cannot drift from
|
||||
the gate's own view (a raw-config re-read missed the managed overlay and ``${VAR}`` expansion)."""
|
||||
from tools.approval import _get_approval_mode
|
||||
mode = _get_approval_mode()
|
||||
return mode if mode in _APPROVAL_MODES else "manual"
|
||||
@@ -2000,62 +1889,37 @@ _MOUSE_TRACKING_ALIASES = {
|
||||
|
||||
|
||||
def _display_mouse_tracking(display: dict) -> str:
|
||||
"""Resolve display.mouse_tracking to one of ``off|wheel|buttons|all``.
|
||||
|
||||
Boolean values keep their legacy meaning (``True`` → ``all``, ``False`` →
|
||||
``off``). The ``wheel`` preset (DEC 1000+1006) is the tmux-friendly
|
||||
subset — wheel + click only, no hover events to trigger prompt-row
|
||||
clipboard probes. Legacy ``tui_mouse`` is honored only when
|
||||
``mouse_tracking`` is absent.
|
||||
"""
|
||||
"""display.mouse_tracking → ``off|wheel|buttons|all``. Booleans keep their legacy meaning
|
||||
(True → all, False → off); ``wheel`` (DEC 1000+1006) is the tmux-friendly subset without hover
|
||||
events that trigger prompt-row clipboard probes. Legacy ``tui_mouse`` only when ``mouse_tracking`` is absent."""
|
||||
if not isinstance(display, dict):
|
||||
return "all"
|
||||
if "mouse_tracking" in display:
|
||||
raw = display.get("mouse_tracking")
|
||||
else:
|
||||
raw = display.get("tui_mouse", True)
|
||||
raw = display.get("mouse_tracking") if "mouse_tracking" in display else display.get("tui_mouse", True)
|
||||
if raw is False or raw == 0:
|
||||
return "off"
|
||||
if raw is True or raw is None:
|
||||
return "all"
|
||||
if isinstance(raw, (int, float)):
|
||||
return "all"
|
||||
if isinstance(raw, str):
|
||||
return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all")
|
||||
return "all"
|
||||
|
||||
|
||||
def _load_reasoning_config(model: str = "") -> dict | None:
|
||||
"""Load reasoning effort from config.yaml, respecting per-model overrides.
|
||||
|
||||
Thin wrapper over the shared chokepoint
|
||||
:func:`hermes_constants.resolve_reasoning_config` (per-model override >
|
||||
global ``agent.reasoning_effort``; YAML boolean False = disabled).
|
||||
Closes #21256.
|
||||
"""
|
||||
"""Reasoning effort via the shared chokepoint :func:`hermes_constants.resolve_reasoning_config`
|
||||
(per-model override > global ``agent.reasoning_effort``; YAML False = disabled)."""
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
return resolve_reasoning_config(_load_cfg(), model)
|
||||
|
||||
|
||||
_SERVICE_TIER_ALIASES = {"fast": "priority", "priority": "priority", "on": "priority", "auto": "auto", "cold": "cold"}
|
||||
|
||||
|
||||
def _load_service_tier() -> str | None:
|
||||
raw = (str((_load_cfg().get("agent") or {}).get("service_tier", "") or "") .strip() .lower())
|
||||
if not raw or raw in {"normal", "default", "standard", "off", "none"}:
|
||||
return None
|
||||
if raw in {"fast", "priority", "on"}:
|
||||
return "priority"
|
||||
if raw in {"auto", "cold"}:
|
||||
return raw
|
||||
return None
|
||||
raw = str((_load_cfg().get("agent") or {}).get("service_tier", "") or "").strip().lower()
|
||||
return _SERVICE_TIER_ALIASES.get(raw)
|
||||
|
||||
|
||||
def _load_provider_routing() -> dict:
|
||||
"""OpenRouter provider-routing prefs from config.yaml (``provider_routing``).
|
||||
|
||||
Parity with the messaging gateway (``gateway/run.py::_load_provider_routing``)
|
||||
and the classic CLI: without this the desktop/TUI backend builds agents with
|
||||
no routing prefs, so OpenRouter falls back to its default (effectively random)
|
||||
provider selection even when the user configured ``provider_routing``.
|
||||
"""
|
||||
"""OpenRouter ``provider_routing`` prefs (gateway/CLI parity — without them OpenRouter picks an
|
||||
effectively random provider even when the user configured routing)."""
|
||||
try:
|
||||
return _load_cfg().get("provider_routing", {}) or {}
|
||||
except Exception:
|
||||
@@ -2063,30 +1927,25 @@ def _load_provider_routing() -> dict:
|
||||
|
||||
|
||||
def _load_show_reasoning() -> bool:
|
||||
# Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning
|
||||
# (this loader reads the raw user YAML without the DEFAULT_CONFIG merge).
|
||||
# Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning (no DEFAULT_CONFIG merge here).
|
||||
return bool(_display_cfg().get("show_reasoning", True))
|
||||
|
||||
|
||||
def _load_memory_notifications() -> str:
|
||||
"""Self-improvement review notification mode from config.yaml.
|
||||
|
||||
Parity with the messaging gateway (``gateway/run.py``) and the classic CLI:
|
||||
``display.memory_notifications`` controls whether the background review's
|
||||
"💾 Self-improvement review: …" summary is surfaced. Without this the
|
||||
TUI/desktop backend always behaved as ``"on"`` and silently ignored a user
|
||||
who set ``off``. Accepts ``off`` / ``on`` (default) / ``verbose``; a bool is
|
||||
normalized for back-compat.
|
||||
"""
|
||||
"""``display.memory_notifications`` (``off`` / ``on`` default / ``verbose``; bool normalized) — gates
|
||||
the "💾 Self-improvement review" summary, gateway/CLI parity (this backend used to ignore ``off``)."""
|
||||
raw = _display_cfg().get("memory_notifications")
|
||||
if isinstance(raw, bool):
|
||||
return "on" if raw else "off"
|
||||
return str(raw).lower() if raw else "on"
|
||||
|
||||
|
||||
_TOOL_PROGRESS_MODES = frozenset({"off", "new", "all", "verbose"})
|
||||
|
||||
|
||||
def _load_tool_progress_mode() -> str:
|
||||
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
|
||||
if env in {"off", "new", "all", "verbose"}:
|
||||
if env in _TOOL_PROGRESS_MODES:
|
||||
return env
|
||||
raw = _display_cfg().get("tool_progress", "all")
|
||||
if raw is False:
|
||||
@@ -2094,23 +1953,14 @@ def _load_tool_progress_mode() -> str:
|
||||
if raw is True:
|
||||
return "all"
|
||||
mode = str(raw or "all").strip().lower()
|
||||
return mode if mode in {"off", "new", "all", "verbose"} else "all"
|
||||
return mode if mode in _TOOL_PROGRESS_MODES else "all"
|
||||
|
||||
|
||||
def _gui_surface_toolsets(platform: str) -> set[str]:
|
||||
"""Toolsets that exist because of the CLIENT on the other end, not the host.
|
||||
|
||||
Both entries are off ``_HERMES_CORE_TOOLS`` (no other platform should carry
|
||||
their schema), so this resolver is the one gate that exposes them.
|
||||
``platform`` is the SESSION's source, never a process env var: the desktop
|
||||
may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset, and keying
|
||||
off the env var stripped every pane/browser tool there. See the
|
||||
surface-capability rule in AGENTS.md.
|
||||
"""
|
||||
surfaces = {"project"}
|
||||
if platform == "desktop":
|
||||
surfaces.add("desktop_ui")
|
||||
return surfaces
|
||||
"""Toolsets that exist because of the CLIENT, not the host — both off ``_HERMES_CORE_TOOLS``, so this
|
||||
is the one gate exposing them. ``platform`` is the SESSION's source, never a process env var: the
|
||||
desktop may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset (see AGENTS.md surface rule)."""
|
||||
return {"project", "desktop_ui"} if platform == "desktop" else {"project"}
|
||||
|
||||
|
||||
def _enabled_mcp_server_names() -> tuple[set[str], set[str]]:
|
||||
|
||||
Reference in New Issue
Block a user