Files
hermes-agent/gateway/display_config.py
T

227 lines
8.1 KiB
Python

"""Per-platform display/verbosity resolver (``resolve_display_setting``).
Resolution order, first non-None wins: ``display.platforms.<platform>.<key>`` →
``display.<key>`` → ``_PLATFORM_DEFAULTS[platform][key]`` → ``_GLOBAL_DEFAULTS[key]``.
Exception: ``display.streaming`` is CLI-only; gateway streaming follows the top-level
``streaming`` config unless a per-platform override sets it. Legacy
``display.tool_progress_overrides`` is still read as a ``tool_progress`` fallback.
"""
from __future__ import annotations
from typing import Any
# Settings configurable per-platform; other display settings are CLI-only.
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
# "code" (💭 **Reasoning:** + fenced block), "blockquote" ("> "), "subtext" ("-# " Discord).
"reasoning_style": "code",
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
# Gateway-only assistant/status chatter; mobile platforms opt down to final-answer-first.
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
# busy_input_mode=steer echo ("Steered into current run"); the text still lands in the run.
"busy_steer_ack_enabled": True,
# Delete tool-progress / "⏳ Working" bubbles after a SUCCESSFUL final response on
# platforms that support deletion (Telegram); failed runs keep them as breadcrumbs.
"cleanup_progress": False,
# Working-state text on text-rendering indicators (Slack assistant status):
# "full"/true = verb + argument preview, "verb" = verb only (keeps paths out of
# shared channels), "off"/false = static text.
"live_status": "full",
}
# Tiers: HIGH = editing, personal/team use; MEDIUM = editing but customer-facing;
# LOW = no edit support (progress messages are permanent); MINIMAL = batch delivery.
_TIER_HIGH = {
"tool_progress": "all",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None, # follow global
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
}
_TIER_MEDIUM = {**_TIER_HIGH, "tool_progress": "new"}
_TIER_LOW = {
**_TIER_HIGH,
"tool_progress": "off",
"streaming": False,
"interim_assistant_messages": False,
"long_running_notifications": False,
"busy_ack_detail": False,
}
_TIER_MINIMAL = {**_TIER_LOW, "tool_preview_length": 0}
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Mobile inbox: quiet tool_progress / busy-ack, but keep interim commentary and
# heartbeats so it doesn't look like "typing..." for 30 minutes.
"telegram": {**_TIER_HIGH, "tool_progress": "off", "busy_ack_detail": False},
# Discord's "-# " subtext reads as metadata, so reasoning defaults to it.
"discord": {**_TIER_HIGH, "reasoning_style": "subtext"},
# Slack: Bolt posts cannot be edited like CLI; "new"/"all" spam permanent lines.
"slack": {
**_TIER_MEDIUM,
"tool_progress": "off",
"long_running_notifications": False,
"busy_ack_detail": False,
},
"mattermost": _TIER_MEDIUM,
"matrix": _TIER_MEDIUM,
"feishu": _TIER_MEDIUM,
# Buzz (Nostr) can edit in place but channels are shared community spaces.
"buzz": _TIER_MEDIUM,
"signal": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
"whatsapp_cloud": _TIER_LOW, # adapter lacks edit_message; promote once it lands
# Permanent-message iMessage inboxes (no edit).
"photon": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
# Non-editable, but its native "stream" msgtype gives a typing animation +
# cumulative updates instead of a one-shot markdown drop.
"wecom": {**_TIER_LOW, "streaming": True},
"wecom_callback": _TIER_LOW,
"dingtalk": _TIER_LOW,
"email": _TIER_MINIMAL,
"sms": _TIER_MINIMAL,
"webhook": _TIER_MINIMAL,
"homeassistant": _TIER_MINIMAL,
"api_server": {**_TIER_HIGH, "tool_preview_length": 0},
}
# Canonical set of per-platform overrideable keys (for validation).
OVERRIDEABLE_KEYS = frozenset(_GLOBAL_DEFAULTS.keys())
def resolve_display_setting(
user_config: dict,
platform_key: str,
setting: str,
fallback: Any = None,
) -> Any:
"""Resolve a display setting with per-platform override support.
``platform_key`` is the platform config key (``"telegram"``, ``"slack"``;
see ``_platform_config_key`` in gateway/run.py). Returns *fallback* when
nothing is configured.
"""
display_cfg = user_config.get("display") or {}
# 1. Explicit per-platform override
plat_overrides = (display_cfg.get("platforms") or {}).get(platform_key)
if isinstance(plat_overrides, dict) and plat_overrides.get(setting) is not None:
return _normalise(setting, plat_overrides[setting])
# 1b. Backward compat: display.tool_progress_overrides.<platform>
if setting == "tool_progress":
legacy = display_cfg.get("tool_progress_overrides")
if isinstance(legacy, dict) and legacy.get(platform_key) is not None:
return _normalise(setting, legacy[platform_key])
# 2. Global user setting (display.streaming is CLI-only, see module docstring).
if setting != "streaming" and display_cfg.get(setting) is not None:
return _normalise(setting, display_cfg[setting])
# 3. Built-in platform default, 4. built-in global default
val = _PLATFORM_DEFAULTS.get(platform_key, {}).get(setting)
if val is None:
val = _GLOBAL_DEFAULTS.get(setting)
return fallback if val is None else val
# --- Normalisation of YAML quirks (bare ``off`` → False in YAML 1.1, etc.) ---
_TRUTHY = {"true", "1", "yes", "on"}
_FALSY = {"false", "0", "no"}
def _norm_tool_progress(value: Any) -> str:
if value is False:
return "off"
if value is True:
return "all"
val = str(value).strip().lower()
if val in _FALSY:
return "off"
if val in _TRUTHY:
return "all"
return val if val in {"off", "new", "all", "verbose", "log"} else "all"
def _norm_bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in _TRUTHY | {"raw", "verbose"}
return bool(value)
def _norm_long_running(value: Any) -> Any:
if isinstance(value, str) and value.strip().lower() == "generic":
return "generic"
return _norm_bool(value)
def _norm_cleanup_progress(value: Any) -> bool:
if isinstance(value, str):
return value.lower() in _TRUTHY
return bool(value)
def _norm_live_status(value: Any) -> str:
"""Tri-state: "full" (verb + preview), "verb" (verb only), "off"."""
if value is True:
return "full"
if value is False:
return "off"
val = str(value).strip().lower()
if val in _TRUTHY | {"all"}:
return "full"
if val in _FALSY:
return "off"
return val if val in {"full", "verb", "off"} else "full"
def _norm_choice(choices: tuple[str, ...]) -> Any:
def norm(value: Any) -> str:
val = str(value).lower()
return val if val in choices else choices[0]
return norm
def _norm_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
_NORMALISERS: dict[str, Any] = {
"tool_progress": _norm_tool_progress,
"show_reasoning": _norm_bool,
"streaming": _norm_bool,
"interim_assistant_messages": _norm_bool,
"long_running_notifications": _norm_long_running,
"busy_ack_detail": _norm_bool,
"busy_steer_ack_enabled": _norm_bool,
"thinking_progress": _norm_bool,
"cleanup_progress": _norm_cleanup_progress,
"live_status": _norm_live_status,
"tool_progress_grouping": _norm_choice(("accumulate", "separate")),
"reasoning_style": _norm_choice(("code", "blockquote", "subtext")),
"tool_preview_length": _norm_int,
}
def _normalise(setting: str, value: Any) -> Any:
"""Normalise a user-supplied value for *setting*; unknown settings pass through."""
norm = _NORMALISERS.get(setting)
return norm(value) if norm else value