feat(tools): withdraw tip and tour when the user switches them off

Off should mean the model is never told the tool exists. A switch that
only made the call fail leaves Hermes offering walkthroughs it cannot
give and promising to point at things it cannot point at, which reads as
a broken agent rather than a respected preference.

Both gate on the switch through a shared desktop_ui.user_enabled helper,
which is the reactions check_fn generalized — same config read, same
reason it has to be config rather than an env var: the switch belongs to
the session's client, and the client may be on another machine.
This commit is contained in:
Brooklyn Nicholson
2026-08-27 22:16:17 -05:00
committed by brooklyn!
parent d0e41cda69
commit b293157f7d
8 changed files with 136 additions and 28 deletions

View File

@@ -0,0 +1,63 @@
"""The desktop's Appearance switches, as the AGENT experiences them.
Each of these toggles decides whether a tool is in the model's schema at all,
so the contract under test is end to end: a real ``config.yaml`` in a temp
``HERMES_HOME``, read through the real config loader, answering a real
``check_fn``. Mocking the loader here would test nothing that matters — the
whole failure this guards against was a value that never reached the config.
"""
import textwrap
import pytest
from hermes_constants import get_hermes_home
from tools import desktop_ui
from tools.react_to_message_tool import check_react_requirements
from tools.tip_tool import check_tips_enabled
from tools.tour_tool import check_tours_enabled
@pytest.fixture
def display_config():
"""Write a ``display:`` section into this test's HERMES_HOME."""
def _write(**flags: bool) -> None:
home = get_hermes_home()
home.mkdir(parents=True, exist_ok=True)
body = "".join(f" {name}: {str(on).lower()}\n" for name, on in flags.items())
(home / "config.yaml").write_text(textwrap.dedent("display:\n") + body)
return _write
def test_a_missing_setting_leaves_the_feature_at_its_default():
"""Absence is not an answer: an opt-out feature stays on, opt-in stays off."""
assert desktop_ui.user_enabled("in_app_tips", default=True) is True
assert desktop_ui.user_enabled("message_reactions", default=False) is False
def test_the_users_answer_wins_in_both_directions(display_config):
display_config(in_app_tips=False, message_reactions=True)
assert desktop_ui.user_enabled("in_app_tips", default=True) is False
assert desktop_ui.user_enabled("message_reactions", default=False) is True
@pytest.mark.parametrize(
("setting", "check", "default_on"),
[
("in_app_tips", check_tips_enabled, True),
("in_app_tours", check_tours_enabled, True),
("message_reactions", check_react_requirements, False),
],
)
def test_switching_a_feature_off_withdraws_its_tool(display_config, setting, check, default_on):
"""The point of the mirror: off means the model is never told it exists."""
assert check() is default_on
display_config(**{setting: False})
assert check() is False
display_config(**{setting: True})
assert check() is True

View File

@@ -30,12 +30,13 @@ def test_lives_in_the_gui_surface_toolset(monkeypatch):
assert entry.toolset == "desktop_ui"
def test_is_ungated_like_tour():
"""The Appearance switch governs the app's idle rotation, not this."""
def test_answers_to_the_appearance_switch():
"""Tips off has to mean the model never sees the tool. See
tests/tools/test_display_toggles.py for the config end of it."""
entry = registry.get_entry("tip")
assert entry is not None
assert entry.check_fn is None
assert entry.check_fn is tt.check_tips_enabled
def test_requires_the_desktop_bridge(monkeypatch):

View File

@@ -18,7 +18,15 @@ def test_lives_in_the_gui_surface_toolset(monkeypatch):
assert entry is not None
assert entry.toolset == "desktop_ui"
assert entry.check_fn is None
def test_answers_to_the_appearance_switch():
"""Tours off has to mean the model never sees the tool. See
tests/tools/test_display_toggles.py for the config end of it."""
entry = registry.get_entry("tour")
assert entry is not None
assert entry.check_fn is tt.check_tours_enabled
def test_requires_callback():

View File

@@ -29,6 +29,30 @@ def available() -> bool:
return _emit is not None
def user_enabled(setting: str, default: bool) -> bool:
"""Read one of the desktop's Appearance switches from ``display.<setting>``.
The renderer owns these toggles and mirrors them onto the CONNECTED
gateway's config (``config.set``), so this reads the user's real answer
whether that gateway is local, SSH, URL, or cloud — where an env var would
only ever describe the process. Tool ``check_fn``s call it to withdraw
themselves from the schema when the user has switched the feature off:
Hermes should not be told about a surface it isn't allowed to use.
An unreadable config falls back to ``default``, which is how a feature that
ships on stays on rather than disappearing on a transient read error.
"""
try:
from hermes_cli.config import load_config_readonly
display = load_config_readonly().get("display")
except Exception:
return default
if not isinstance(display, dict) or setting not in display:
return default
return bool(display.get(setting))
def emit(event: str, payload: dict) -> bool:
"""Route ``event`` to the window that owns the current turn.

View File

@@ -120,17 +120,9 @@ def check_react_requirements() -> bool:
"""Opt-in feature flag — surface eligibility is the toolset's job.
``desktop_ui`` already restricts this to GUI sessions. What's left is the
user's own toggle (Settings → Appearance), which the desktop mirrors into
``display.message_reactions`` on the CONNECTED gateway's config — so this
reads the right config whether that gateway is local, SSH, URL, or cloud.
user's own toggle (Settings → Appearance).
"""
try:
from hermes_cli.config import load_config_readonly
display = load_config_readonly().get("display")
except Exception:
return False
return isinstance(display, dict) and bool(display.get("message_reactions", False))
return desktop_ui.user_enabled("message_reactions", default=False)
REACT_TO_MESSAGE_SCHEMA = {

View File

@@ -10,12 +10,11 @@ sentence.
Fire-and-forget, unlike ``tour``: a tip is not a question, so blocking the turn
on a round-trip would stall the reply it belongs to.
Ungated, also like ``tour``. The desktop's Settings → Appearance switch governs
the app's own idle rotation — the half that talks unprompted — not this, which
Hermes raises mid-conversation in answer to something the user said.
Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for
desktop-sourced sessions.
desktop-sourced sessions, and withdraws itself entirely when the user has
turned tips off (Settings → Appearance). Off means the model is never told the
tool exists — a switch that only made the call fail would leave Hermes
promising to point at things it cannot point at.
"""
import json
@@ -95,6 +94,11 @@ TIP_SCHEMA = {
}
def check_tips_enabled() -> bool:
"""The user's Settings → Appearance switch. On unless they turned it off."""
return desktop_ui.user_enabled("in_app_tips", default=True)
registry.register(
name="tip",
toolset="desktop_ui",
@@ -105,5 +109,6 @@ registry.register(
title=args.get("title", ""),
side=args.get("side", ""),
),
check_fn=check_tips_enabled,
emoji="💡",
)

View File

@@ -19,12 +19,16 @@ outcome, so the agent knows whether the selector matched. This module is just
schema + a thin dispatcher over the platform-injected callback.
Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for
desktop-sourced sessions.
desktop-sourced sessions, and withdraws itself when the user has switched tours
off (Settings → Appearance). A tour takes the whole screen, so "no thanks" has
to mean the model is never told the tool exists — a switch that only made the
call fail would leave Hermes offering walkthroughs it cannot give.
"""
import json
from typing import Callable, Optional
from tools import desktop_ui
from tools.registry import registry, tool_error
ACTIONS = ("targets", "show", "start", "next", "prev", "stop")
@@ -179,6 +183,11 @@ TOUR_SCHEMA = {
}
def check_tours_enabled() -> bool:
"""The user's Settings → Appearance switch. On unless they turned it off."""
return desktop_ui.user_enabled("in_app_tours", default=True)
registry.register(
name="tour",
toolset="desktop_ui",
@@ -194,5 +203,6 @@ registry.register(
step_index=args.get("step_index"),
callback=kw.get("callback"),
),
check_fn=check_tours_enabled,
emoji="🧭",
)

View File

@@ -265,14 +265,19 @@ discovery is one call for both, and the durable `data-tour` handles above name
targets for either. One tip is on screen at a time; a new one replaces the last.
The app can also show its own, walking a built-in catalog of app features in
order. That half is on by default and switched off in Settings → Appearance,
and it is paced like a game's loading-screen tips rather than a notification: a
few minutes into a launch at the earliest, then at most one every six hours, and
only at a genuinely idle moment. Closing one of its tips with the ✕ retires that
tip for good, and the same settings row brings them back. The tool is not behind
that switch — like `tour`, it runs in answer to the conversation rather than at
idle. It does share the cooldown, so a tip from Hermes also buys the user six
hours of quiet from the rotation.
order, paced like a game's loading-screen tips rather than a notification: a few
minutes into a launch at the earliest, then at most one every six hours, and
only at a genuinely idle moment. A tip from Hermes shares that cooldown, so it
also buys the user six hours of quiet from the rotation. Closing a rotation tip
with the ✕ retires that tip for good, and the settings row brings them back.
Both tips and tours are on by default and switched off in Settings → Appearance
(`display.in_app_tips`, `display.in_app_tours`). Off covers Hermes as well as
the app: the switch reaches the connected gateway's config and the tool leaves
the model's schema, so the agent is never told about a surface it isn't allowed
to use. Like every schema change, that lands on the next session — a running
conversation keeps the toolset it started with, and the app declines the call in
the meantime.
## `todo` toolset