From e67fd76fa26bf3c219bb78159359060d32ede206 Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Wed, 23 Sep 2026 06:50:10 +0900 Subject: [PATCH] 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. 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) --- cli-config.yaml.example | 34 ++++++++++++------- hermes_cli/config.py | 10 +++++- hermes_cli/setup.py | 3 +- hermes_cli/setup_quick.py | 1 - tests/gateway/test_display_config.py | 22 ++++++++++++ tests/hermes_cli/test_config_edit_seed.py | 30 ++++++++++++++++ tests/hermes_cli/test_setup_agent_settings.py | 18 ++++++++++ tests/hermes_cli/test_setup_blank_slate.py | 9 +++++ website/docs/user-guide/messaging/index.md | 2 ++ 9 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 tests/hermes_cli/test_config_edit_seed.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index a622c100fd..77baaf2cfa 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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. 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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5450a88314..ecc922f479 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index f2fcf3c8a7..b933c231b7 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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.") diff --git a/hermes_cli/setup_quick.py b/hermes_cli/setup_quick.py index 1696d1603c..cd2a0116b3 100644 --- a/hermes_cli/setup_quick.py +++ b/hermes_cli/setup_quick.py @@ -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: diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 5620694620..0008dd55bb 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -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.`` 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 diff --git a/tests/hermes_cli/test_config_edit_seed.py b/tests/hermes_cli/test_config_edit_seed.py new file mode 100644 index 0000000000..8651f0552d --- /dev/null +++ b/tests/hermes_cli/test_config_edit_seed.py @@ -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) diff --git a/tests/hermes_cli/test_setup_agent_settings.py b/tests/hermes_cli/test_setup_agent_settings.py index 6aa725fb25..68429d3cff 100644 --- a/tests/hermes_cli/test_setup_agent_settings.py +++ b/tests/hermes_cli/test_setup_agent_settings.py @@ -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 diff --git a/tests/hermes_cli/test_setup_blank_slate.py b/tests/hermes_cli/test_setup_blank_slate.py index 6c16ea4425..d54d01bf74 100644 --- a/tests/hermes_cli/test_setup_blank_slate.py +++ b/tests/hermes_cli/test_setup_blank_slate.py @@ -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.""" diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 1ad2373964..03098e7afe 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -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