fix(config): stop seeding global display values that override every messaging platform's defaults

The curl installer, the Windows installer, the Docker first boot and
`hermes doctor --fix` copy cli-config.yaml.example into config.yaml byte
for byte. The template had five display keys uncommented: tool_progress,
interim_assistant_messages, long_running_notifications, busy_ack_detail
and show_reasoning. The gateway reads config.yaml without a DEFAULT_CONFIG
merge, and resolve_display_setting takes a global display.<key> ahead of
_PLATFORM_DEFAULTS. So every seeded home ran with those values on every
platform. Telegram and Slack posted every tool call. Signal, email, SMS
and the other no-edit platforms got progress lines, heartbeats and
interim messages. Every messaging reply had the reasoning block prepended.

First-time `hermes setup` (quick and full) and Blank Slate setup also
wrote display.tool_progress: "all". That write was added as a Quick
Install recommended default (79aeaa97e6) nine days before the
per-platform tiers landed (#8006), and it has the same effect for
tool_progress on homes the template never touched.

`hermes config edit` on a home with no config.yaml wrote DEFAULT_CONFIG
unstripped, which pins show_reasoning (all 21 platforms),
interim_assistant_messages (12) and tool_preview_length (16). It now
seeds like the installer and `doctor --fix`: the template when the
checkout has one (a full file to edit, written owner-only), otherwise
DEFAULT_CONFIG with defaults stripped.

Measured through the real gateway loader across the 21 platforms in
_PLATFORM_DEFAULTS, a template-seeded home differed from a bare one on
tool_progress for 19 platforms, show_reasoning for 21, busy_ack_detail
for 14, long_running_notifications for 13 and interim_assistant_messages
for 12. With the pins commented out and the setup writes removed, the
diff is empty, and the same holds for both `config edit` seeds.

The CLI does not depend on these values. It defaults tool_progress to
"all" and show_reasoning to true when the keys are absent, and the TUI
defaults interim_assistant_messages to true.

Homes that were already seeded keep their values. A template value
cannot be told apart from one the operator chose, so there is no
migration. The messaging docs now say which lines to delete.

(cherry picked from commit 96450d4500613ab1ba45c7e972f31de570bc2d71)
This commit is contained in:
John Paul Soliva
2026-09-23 06:50:10 +09:00
committed by kshitij
parent 17aa453689
commit e67fd76fa2
9 changed files with 112 additions and 17 deletions

View File

@@ -1817,9 +1817,15 @@ display:
# verbose: Full args, results, and debug logs (same as /verbose)
# log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only)
# Toggle at runtime with /verbose in the CLI
tool_progress: all
# tool_progress: all
# Per-platform defaults can be quieter than the global setting. Telegram
# tool_progress, interim_assistant_messages, long_running_notifications,
# busy_ack_detail and show_reasoning are left unset here on purpose: a value
# set under display: applies to EVERY messaging platform and replaces its
# built-in default. To change one platform, set it under
# display.platforms.<platform> instead.
#
# Per-platform defaults can be quieter than the CLI. Telegram
# tunes for mobile: tool_progress and busy_ack_detail default off (no
# per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy
# acks or heartbeats), but interim_assistant_messages and
@@ -1850,9 +1856,10 @@ display:
# assistant narration streamed between tool calls is kept in the transcript
# instead of the bubble collapsing to only the final message on completion.
# Independent of tool_progress and gateway streaming.
# true: Keep/send mid-turn assistant updates (default)
# true: Keep/send mid-turn assistant updates (default; off on platforms
# that cannot edit messages)
# false: Only keep/send the final response
interim_assistant_messages: true
# interim_assistant_messages: true
# Opt-in: hide automatic warning/diagnostic notifications (compression, retry
# and fallback notices, credit/subagent failure lines, inactivity and watchdog
@@ -1867,16 +1874,17 @@ display:
# notifications even if agent.gateway_notify_interval is non-zero. The
# heartbeat edits a single message in place (where the adapter supports
# editing) instead of posting a new bubble each interval.
# Default: true everywhere, including Telegram (silent agents are worse
# than a single edit-in-place heartbeat).
long_running_notifications: true
# Default: true, including Telegram (silent agents are worse than a single
# edit-in-place heartbeat); false on Slack and on platforms that cannot edit.
# long_running_notifications: true
# Include detailed iteration/tool/status context in busy acknowledgments
# and long-running heartbeats. When true, busy acks show "iteration 21/60,
# terminal, 10 min" and the heartbeat shows "⏳ Working — 12 min,
# iteration 21/60, terminal". When false (Telegram default), both stay
# terse: "Interrupting current task" and "⏳ Working — 12 min, terminal".
busy_ack_detail: true
# iteration 21/60, terminal". When false (Telegram, Slack and no-edit
# platform default), both stay terse: "Interrupting current task" and
# "⏳ Working — 12 min, terminal".
# busy_ack_detail: true
# What Enter does when Hermes is already busy (CLI and gateway platforms).
# interrupt: Interrupt the current run and redirect Hermes (default)
@@ -1918,9 +1926,9 @@ display:
# Show model reasoning/thinking before each response.
# When enabled, a dim box shows the model's thought process above the response.
# Toggle at runtime with /reasoning show or /reasoning hide.
# true: Show the reasoning box (default)
# false: Hide reasoning
show_reasoning: true
# true: Show the reasoning box (CLI default)
# false: Hide reasoning (messaging platform default)
# show_reasoning: true
# Stream tokens to the terminal as they arrive instead of waiting for the
# full response. The response box opens on first token and text appears

View File

@@ -3039,7 +3039,15 @@ def edit_config():
return
config_path = get_config_path()
if not config_path.exists():
save_config(DEFAULT_CONFIG, strip_defaults=False)
# Seed like the installer and `hermes doctor --fix`: DEFAULT_CONFIG written verbatim pins CLI display values
# (show_reasoning, interim_assistant_messages, ...) globally, over every messaging platform's own defaults.
template = get_project_root() / "cli-config.yaml.example"
if template.exists():
config_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template, config_path)
_secure_file(config_path)
else:
save_config(DEFAULT_CONFIG)
print(f"Created {config_path}")
# Windows lands on notepad even without Git Bash/nano; POSIX prefers nano/vim, which headless

View File

@@ -408,12 +408,11 @@ def _apply_default_agent_settings(config: dict):
# config.yaml is authoritative for max_turns (the gateway bridges it into HERMES_MAX_ITERATIONS);
# a stale .env entry silently shadowing it caused the 60-vs-500 bug, so drop it.
remove_env_value("HERMES_MAX_ITERATIONS")
config.setdefault("display", {})["tool_progress"] = "all"
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
save_config(config)
print_success("Applied recommended defaults:")
_info(" Max iterations: 150", " Tool progress: all", " Compression threshold: 0.50",
_info(" Max iterations: 150", " Compression threshold: 0.50",
" Run `hermes setup agent` later to customize.")

View File

@@ -200,7 +200,6 @@ def _blank_slate_minimize_config(config: dict):
mem["user_profile_enabled"] = False
config.setdefault("checkpoints", {})["enabled"] = False
config.setdefault("smart_model_routing", {})["enabled"] = False
config.setdefault("display", {})["tool_progress"] = "all"
def _set_bundled_skills_opt_out(opt_out: bool, log_label: str, on_success=None, on_error=None) -> None:

View File

@@ -139,6 +139,28 @@ class TestYAMLNormalisation:
# ---------------------------------------------------------------------------
def test_shipped_template_keeps_every_platform_default(self, tmp_path):
"""The installers, the Docker first boot and ``doctor --fix`` copy
cli-config.yaml.example verbatim, so an uncommented ``display.<key>`` there
becomes an explicit global value that beats every platform tier."""
import shutil
from pathlib import Path
from gateway.display_config import _PLATFORM_DEFAULTS, resolve_display_setting, resolve_tool_progress
from gateway.run import _load_gateway_config
template = Path(__file__).resolve().parents[2] / "cli-config.yaml.example"
shutil.copy(template, tmp_path / "config.yaml")
seeded = _load_gateway_config(tmp_path / "config.yaml")
assert "display" in seeded # the loader fails open to {}, which would pass vacuously
tier_keys = {key for tier in _PLATFORM_DEFAULTS.values() for key in tier}
for platform in _PLATFORM_DEFAULTS:
assert resolve_tool_progress(seeded, platform) == resolve_tool_progress({}, platform), platform
for key in tier_keys:
assert resolve_display_setting(seeded, platform, key) == resolve_display_setting({}, platform, key), (
platform, key)
# ---------------------------------------------------------------------------
# Config migration: tool_progress_overrides → display.platforms

View File

@@ -0,0 +1,30 @@
"""``hermes config edit`` on a home with no config.yaml seeds one. The seed must not pin a display value over
any messaging platform's own default (the gateway loader merges no DEFAULT_CONFIG, so every written key is explicit)."""
import pytest
@pytest.mark.parametrize("seed", ["template", "no-template"])
def test_config_edit_seed_keeps_every_platform_display_default(tmp_path, monkeypatch, seed):
import hermes_cli.config as cfg
from gateway.display_config import _PLATFORM_DEFAULTS, resolve_display_setting, resolve_tool_progress
from gateway.run import _load_gateway_config
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("EDITOR", "true")
monkeypatch.setattr(cfg.subprocess, "run", lambda *a, **k: None)
if seed == "no-template":
monkeypatch.setattr(cfg, "get_project_root", lambda: tmp_path / "no-checkout")
cfg.edit_config()
config_path = home / "config.yaml"
assert config_path.exists() # the resolution checks below would pass on a missing file
seeded = _load_gateway_config(config_path)
tier_keys = {key for tier in _PLATFORM_DEFAULTS.values() for key in tier}
for platform in _PLATFORM_DEFAULTS:
assert resolve_tool_progress(seeded, platform) == resolve_tool_progress({}, platform), platform
for key in tier_keys:
assert resolve_display_setting(seeded, platform, key) == resolve_display_setting({}, platform, key), (
platform, key)

View File

@@ -46,3 +46,21 @@ def test_setup_agent_settings_prefers_config_over_stale_env(tmp_path, monkeypatc
assert "Press Enter to keep 60." not in out
# And the stale .env entry gets cleaned up
assert "HERMES_MAX_ITERATIONS" in removed_keys
def test_first_time_defaults_keep_every_platform_tool_progress_default(tmp_path, monkeypatch):
"""Quick and full first-time setup run this. A global display.tool_progress it
writes beats every platform tier, so Telegram and Slack went from off to all."""
from gateway.display_config import _PLATFORM_DEFAULTS, resolve_tool_progress
from gateway.run import _load_gateway_config
from hermes_cli.config import load_config
from hermes_cli.setup import _apply_default_agent_settings
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_apply_default_agent_settings(load_config())
on_disk = _load_gateway_config(tmp_path / "config.yaml")
assert on_disk.get("agent", {}).get("max_turns") == 150 # the save really landed
for platform in _PLATFORM_DEFAULTS:
assert resolve_tool_progress(on_disk, platform) == resolve_tool_progress({}, platform), platform

View File

@@ -83,6 +83,15 @@ class TestBlankSlateMinimizeConfig:
assert cfg["checkpoints"]["enabled"] is False
assert cfg["smart_model_routing"]["enabled"] is False
def test_messaging_platforms_keep_their_tool_progress_default(self):
"""A global display.tool_progress beats every platform tier (Telegram/Slack off)."""
from gateway.display_config import _PLATFORM_DEFAULTS, resolve_tool_progress
cfg = {}
_blank_slate_minimize_config(cfg)
for platform in _PLATFORM_DEFAULTS:
assert resolve_tool_progress(cfg, platform) == resolve_tool_progress({}, platform), platform
class TestBlankSlateFork:
"""The post-baseline fork: finish now vs walk through configurations."""

View File

@@ -881,6 +881,8 @@ Telegram is usually a mobile inbox, so the defaults are tuned for that surface:
- **`interim_assistant_messages`** stays **on** — real mid-turn assistant commentary (the model literally telling you what it's about to do) is signal, not noise.
- **`long_running_notifications`** stays **on** — a single edit-in-place "⏳ Working — N min" bubble updates every few minutes so you have a heartbeat instead of staring at `typing…` for half an hour.
These per-platform defaults apply only while the same key is unset directly under `display:`. A global `display.tool_progress`, `display.show_reasoning`, `display.busy_ack_detail`, `display.interim_assistant_messages` or `display.long_running_notifications` applies to every platform and replaces its default. A `config.yaml` copied from an older `cli-config.yaml.example` sets all five globally, and an older first-time `hermes setup` wrote `tool_progress: all`; delete those lines to get the per-platform defaults back.
Opt out of either of the kept-on defaults or opt back into verbose progress per platform:
```yaml