Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions gateway/display_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Per-platform display/verbosity configuration resolver.

Provides ``resolve_display_setting()`` — the single entry-point for reading
display settings with platform-specific overrides and sensible defaults.

Resolution order (first non-None wins):
1. ``display.platforms.<platform>.<key>`` — explicit per-platform user override
2. ``display.<key>`` — global user setting
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in sensible default
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default

Backward compatibility: ``display.tool_progress_overrides`` is still read as a
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
config migration (version bump) automatically moves the old format into the new
``display.platforms`` structure.
"""

from __future__ import annotations

from typing import Any

# ---------------------------------------------------------------------------
# Overrideable display settings and their global defaults
# ---------------------------------------------------------------------------
# These are the settings that can be configured per-platform.
# Other display settings (compact, personality, skin, etc.) are CLI-only
# and don't participate in per-platform resolution.

_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
}

# ---------------------------------------------------------------------------
# Sensible per-platform defaults — tiered by platform capability
# ---------------------------------------------------------------------------
# Tier 1 (high): Supports message editing, typically personal/team use
# Tier 2 (medium): Supports editing but often workspace/customer-facing
# Tier 3 (low): No edit support — each progress msg is permanent
# Tier 4 (minimal): Batch/non-interactive delivery

_TIER_HIGH = {
"tool_progress": "all",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None, # follow global
}

_TIER_MEDIUM = {
"tool_progress": "new",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None,
}

_TIER_LOW = {
"tool_progress": "off",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": False,
}

_TIER_MINIMAL = {
"tool_progress": "off",
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": False,
}

_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Tier 1 — full edit support, personal/team use
"telegram": _TIER_HIGH,
"discord": _TIER_HIGH,

# Tier 2 — edit support, often customer/workspace channels
"slack": _TIER_MEDIUM,
"mattermost": _TIER_MEDIUM,
"matrix": _TIER_MEDIUM,
"feishu": _TIER_MEDIUM,

# Tier 3 — no edit support, progress messages are permanent
"signal": _TIER_LOW,
"whatsapp": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
"wecom": _TIER_LOW,
"wecom_callback": _TIER_LOW,
"dingtalk": _TIER_LOW,

# Tier 4 — batch or non-interactive delivery
"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.

Parameters
----------
user_config : dict
The full parsed config.yaml dict.
platform_key : str
Platform config key (e.g. ``"telegram"``, ``"slack"``). Use
``_platform_config_key(source.platform)`` from gateway/run.py.
setting : str
Display setting name (e.g. ``"tool_progress"``, ``"show_reasoning"``).
fallback : Any
Fallback value when the setting isn't found anywhere.

Returns
-------
The resolved value, or *fallback* if nothing is configured.
"""
display_cfg = user_config.get("display") or {}

# 1. Explicit per-platform override (display.platforms.<platform>.<key>)
platforms = display_cfg.get("platforms") or {}
plat_overrides = platforms.get(platform_key)
if isinstance(plat_overrides, dict):
val = plat_overrides.get(setting)
if val is not None:
return _normalise(setting, val)

# 1b. Backward compat: display.tool_progress_overrides.<platform>
if setting == "tool_progress":
legacy = display_cfg.get("tool_progress_overrides")
if isinstance(legacy, dict):
val = legacy.get(platform_key)
if val is not None:
return _normalise(setting, val)

# 2. Global user setting (display.<key>)
val = display_cfg.get(setting)
if val is not None:
return _normalise(setting, val)

# 3. Built-in platform default
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
if plat_defaults:
val = plat_defaults.get(setting)
if val is not None:
return val

# 4. Built-in global default
val = _GLOBAL_DEFAULTS.get(setting)
if val is not None:
return val

return fallback


def get_platform_defaults(platform_key: str) -> dict[str, Any]:
"""Return the built-in default display settings for a platform.

Falls back to ``_GLOBAL_DEFAULTS`` for unknown platforms.
"""
return dict(_PLATFORM_DEFAULTS.get(platform_key, _GLOBAL_DEFAULTS))


def get_effective_display(user_config: dict, platform_key: str) -> dict[str, Any]:
"""Return the fully-resolved display settings for a platform.

Useful for status commands that want to show all effective settings.
"""
return {
key: resolve_display_setting(user_config, platform_key, key)
for key in OVERRIDEABLE_KEYS
}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _normalise(setting: str, value: Any) -> Any:
"""Normalise YAML quirks (bare ``off`` → False in YAML 1.1)."""
if setting == "tool_progress":
if value is False:
return "off"
if value is True:
return "all"
return str(value).lower()
if setting in ("show_reasoning", "streaming"):
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
return bool(value)
if setting == "tool_preview_length":
try:
return int(value)
except (TypeError, ValueError):
return 0
return value
101 changes: 62 additions & 39 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3461,8 +3461,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
session_entry.session_id = agent_result["session_id"]

# Prepend reasoning/thinking if display is enabled
if getattr(self, "_show_reasoning", False) and response:
# Prepend reasoning/thinking if display is enabled (per-platform)
try:
from gateway.display_config import resolve_display_setting as _rds
_show_reasoning_effective = _rds(
_load_gateway_config(),
_platform_config_key(source.platform),
"show_reasoning",
getattr(self, "_show_reasoning", False),
)
except Exception:
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
if _show_reasoning_effective and response:
last_reasoning = agent_result.get("last_reasoning")
if last_reasoning:
# Collapse long reasoning to keep messages readable
Expand Down Expand Up @@ -5448,16 +5458,20 @@ def _save_config_key(key_path: str, value):
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`"
)

# Display toggle
# Display toggle (per-platform)
platform_key = _platform_config_key(event.source.platform)
if args in ("show", "on"):
self._show_reasoning = True
_save_config_key("display.show_reasoning", True)
return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
return (
"🧠 ✓ Reasoning display: **ON**\n"
f"Model thinking will be shown before each response on **{platform_key}**."
)

if args in ("hide", "off"):
self._show_reasoning = False
_save_config_key("display.show_reasoning", False)
return "🧠 ✓ Reasoning display: **OFF**"
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**"

# Effort level change
effort = args.strip()
Expand Down Expand Up @@ -5560,11 +5574,14 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:

Gated by ``display.tool_progress_command`` in config.yaml (default off).
When enabled, cycles the tool progress mode through off → new → all →
verbose → off, same as the CLI.
verbose → off for the *current platform*. The setting is saved to
``display.platforms.<platform>.tool_progress`` so each channel can
have its own verbosity level independently.
"""
import yaml

config_path = _hermes_home / "config.yaml"
platform_key = _platform_config_key(event.source.platform)

# --- check config gate ------------------------------------------------
try:
Expand All @@ -5583,7 +5600,7 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:
"display:\n tool_progress_command: true\n```"
)

# --- cycle mode -------------------------------------------------------
# --- cycle mode (per-platform) ----------------------------------------
cycle = ["off", "new", "all", "verbose"]
descriptions = {
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
Expand All @@ -5592,26 +5609,29 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:
"verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.",
}

raw_progress = user_config.get("display", {}).get("tool_progress", "all")
# YAML 1.1 parses bare "off" as boolean False — normalise back
if raw_progress is False:
current = "off"
elif raw_progress is True:
current = "all"
else:
current = str(raw_progress).lower()
# Read current effective mode for this platform via the resolver
from gateway.display_config import resolve_display_setting
current = resolve_display_setting(user_config, platform_key, "tool_progress", "all")
if current not in cycle:
current = "all"
idx = (cycle.index(current) + 1) % len(cycle)
new_mode = cycle[idx]

# Save to config.yaml
# Save to display.platforms.<platform>.tool_progress
try:
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
user_config["display"] = {}
user_config["display"]["tool_progress"] = new_mode
display = user_config["display"]
if "platforms" not in display or not isinstance(display.get("platforms"), dict):
display["platforms"] = {}
if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
display["platforms"][platform_key] = {}
display["platforms"][platform_key]["tool_progress"] = new_mode
atomic_yaml_write(config_path, user_config)
return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
return (
f"{descriptions[new_mode]}\n"
f"_(saved for **{platform_key}** — takes effect on next message)_"
)
except Exception as e:
logger.warning("Failed to save tool_progress mode: %s", e)
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
Expand Down Expand Up @@ -7083,32 +7103,23 @@ async def _run_agent(
if not isinstance(display_config, dict):
display_config = {}

# Per-platform display settings — resolve via display_config module
# which checks display.platforms.<platform>.<key> first, then
# display.<key> global, then built-in platform defaults.
from gateway.display_config import resolve_display_setting

# Apply tool preview length config (0 = no limit)
try:
from agent.display import set_tool_preview_max_len
_tpl = display_config.get("tool_preview_length", 0)
_tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0)
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
except Exception:
pass

# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
# Falls back to env vars for backward compatibility.
# YAML 1.1 parses bare `off` as boolean False — normalise before
# the `or` chain so it doesn't silently fall through to "all".
#
# Per-platform overrides (display.tool_progress_overrides) take
# priority over the global setting — e.g. Signal users can set
# tool_progress to "off" while keeping Telegram on "all".
_overrides = display_config.get("tool_progress_overrides", {})
if not isinstance(_overrides, dict):
_overrides = {}
_raw_tp = _overrides.get(platform_key)
if _raw_tp is None:
_raw_tp = display_config.get("tool_progress")
if _raw_tp is False:
_raw_tp = "off"
# Tool progress mode — resolved per-platform with env var fallback
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
progress_mode = (
_raw_tp
_resolved_tp
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all"
)
Expand Down Expand Up @@ -7446,7 +7457,19 @@ def run_sync():
from gateway.config import StreamingConfig
_scfg = StreamingConfig()

_want_stream_deltas = _scfg.enabled and _scfg.transport != "off"
# Per-platform streaming gate: display.platforms.<plat>.streaming
# can disable streaming for specific platforms even when the global
# streaming config is enabled.
_plat_streaming = resolve_display_setting(
user_config, platform_key, "streaming"
)
# None = no per-platform override → follow global config
_streaming_enabled = (
_scfg.enabled and _scfg.transport != "off"
if _plat_streaming is None
else bool(_plat_streaming)
)
_want_stream_deltas = _streaming_enabled
_want_interim_messages = interim_assistant_messages_enabled
_want_interim_consumer = _want_interim_messages
if _want_stream_deltas or _want_interim_consumer:
Expand Down
Loading
Loading