feat: config-gated /verbose command for messaging gateway (#3262)
* feat: config-gated /verbose command for messaging gateway Add gateway_config_gate field to CommandDef, allowing cli_only commands to be conditionally available in the gateway based on a config value. - CommandDef gains gateway_config_gate: str | None — a config dotpath that, when truthy, overrides cli_only for gateway surfaces - /verbose uses gateway_config_gate='display.tool_progress_command' - Default is off (cli_only behavior preserved) - When enabled, /verbose cycles tool_progress mode (off/new/all/verbose) in the gateway, saving to config.yaml — same cycle as the CLI - Gateway helpers (help, telegram menus, slack mapping) dynamically check config to include/exclude config-gated commands - GATEWAY_KNOWN_COMMANDS always includes config-gated commands so the gateway recognizes them and can respond appropriately - Handles YAML 1.1 bool coercion (bare 'off' parses as False) - 8 new tests for the config gate mechanism + gateway handler * docs: document gateway_config_gate and /verbose messaging support - AGENTS.md: add gateway_config_gate to CommandDef fields - slash-commands.md: note /verbose can be enabled for messaging, update Notes - configuration.md: add tool_progress_command to display section + usage note - cli.md: cross-link to config docs for messaging enablement - messaging/index.md: show tool_progress_command in config snippet - plugins.md: add gateway_config_gate to register_command parameter table
This commit is contained in:
@@ -173,6 +173,7 @@ if canonical == "mycommand":
|
|||||||
- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
|
- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
|
||||||
- `cli_only` — only available in the interactive CLI
|
- `cli_only` — only available in the interactive CLI
|
||||||
- `gateway_only` — only available in messaging platforms
|
- `gateway_only` — only available in messaging platforms
|
||||||
|
- `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
|
||||||
|
|
||||||
**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
|
**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
|
||||||
|
|
||||||
|
|||||||
@@ -1716,6 +1716,9 @@ class GatewayRunner:
|
|||||||
if canonical == "reasoning":
|
if canonical == "reasoning":
|
||||||
return await self._handle_reasoning_command(event)
|
return await self._handle_reasoning_command(event)
|
||||||
|
|
||||||
|
if canonical == "verbose":
|
||||||
|
return await self._handle_verbose_command(event)
|
||||||
|
|
||||||
if canonical == "provider":
|
if canonical == "provider":
|
||||||
return await self._handle_provider_command(event)
|
return await self._handle_provider_command(event)
|
||||||
|
|
||||||
@@ -3784,6 +3787,68 @@ class GatewayRunner:
|
|||||||
else:
|
else:
|
||||||
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
||||||
|
|
||||||
|
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
||||||
|
"""Handle /verbose command — cycle tool progress display mode.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = _hermes_home / "config.yaml"
|
||||||
|
|
||||||
|
# --- check config gate ------------------------------------------------
|
||||||
|
try:
|
||||||
|
user_config = {}
|
||||||
|
if config_path.exists():
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
user_config = yaml.safe_load(f) or {}
|
||||||
|
gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
|
||||||
|
except Exception:
|
||||||
|
gate_enabled = False
|
||||||
|
|
||||||
|
if not gate_enabled:
|
||||||
|
return (
|
||||||
|
"The `/verbose` command is not enabled for messaging platforms.\n\n"
|
||||||
|
"Enable it in `config.yaml`:\n```yaml\n"
|
||||||
|
"display:\n tool_progress_command: true\n```"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- cycle mode -------------------------------------------------------
|
||||||
|
cycle = ["off", "new", "all", "verbose"]
|
||||||
|
descriptions = {
|
||||||
|
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
|
||||||
|
"new": "⚙️ Tool progress: **NEW** — shown when tool changes.",
|
||||||
|
"all": "⚙️ Tool progress: **ALL** — every tool call shown.",
|
||||||
|
"verbose": "⚙️ Tool progress: **VERBOSE** — full args and results.",
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if current not in cycle:
|
||||||
|
current = "all"
|
||||||
|
idx = (cycle.index(current) + 1) % len(cycle)
|
||||||
|
new_mode = cycle[idx]
|
||||||
|
|
||||||
|
# Save to config.yaml
|
||||||
|
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
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||||
|
return f"{descriptions[new_mode]}\n_(saved to config — 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})_"
|
||||||
|
|
||||||
async def _handle_compress_command(self, event: MessageEvent) -> str:
|
async def _handle_compress_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /compress command -- manually compress conversation context."""
|
"""Handle /compress command -- manually compress conversation context."""
|
||||||
source = event.source
|
source = event.source
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class CommandDef:
|
|||||||
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
||||||
cli_only: bool = False # only available in CLI
|
cli_only: bool = False # only available in CLI
|
||||||
gateway_only: bool = False # only available in gateway/messaging
|
gateway_only: bool = False # only available in gateway/messaging
|
||||||
|
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,7 +88,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||||||
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
||||||
cli_only=True, aliases=("sb",)),
|
cli_only=True, aliases=("sb",)),
|
||||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||||
"Configuration", cli_only=True),
|
"Configuration", cli_only=True,
|
||||||
|
gateway_config_gate="display.tool_progress_command"),
|
||||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||||
args_hint="[level|show|hide]",
|
args_hint="[level|show|hide]",
|
||||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||||
@@ -205,7 +207,7 @@ def rebuild_lookups() -> None:
|
|||||||
GATEWAY_KNOWN_COMMANDS = frozenset(
|
GATEWAY_KNOWN_COMMANDS = frozenset(
|
||||||
name
|
name
|
||||||
for cmd in COMMAND_REGISTRY
|
for cmd in COMMAND_REGISTRY
|
||||||
if not cmd.cli_only
|
if not cmd.cli_only or cmd.gateway_config_gate
|
||||||
for name in (cmd.name, *cmd.aliases)
|
for name in (cmd.name, *cmd.aliases)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,20 +261,76 @@ for _cmd in COMMAND_REGISTRY:
|
|||||||
# Gateway helpers
|
# Gateway helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Set of all command names + aliases recognized by the gateway
|
# Set of all command names + aliases recognized by the gateway.
|
||||||
|
# Includes config-gated commands so the gateway can dispatch them
|
||||||
|
# (the handler checks the config gate at runtime).
|
||||||
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
||||||
name
|
name
|
||||||
for cmd in COMMAND_REGISTRY
|
for cmd in COMMAND_REGISTRY
|
||||||
if not cmd.cli_only
|
if not cmd.cli_only or cmd.gateway_config_gate
|
||||||
for name in (cmd.name, *cmd.aliases)
|
for name in (cmd.name, *cmd.aliases)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_gates() -> set[str]:
|
||||||
|
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
|
||||||
|
|
||||||
|
Reads ``config.yaml`` and walks the dot-separated key path for each
|
||||||
|
config-gated command. Returns an empty set on any error so callers
|
||||||
|
degrade gracefully.
|
||||||
|
"""
|
||||||
|
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
|
||||||
|
if not gated:
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
config_path = os.path.join(
|
||||||
|
os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")),
|
||||||
|
"config.yaml",
|
||||||
|
)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
else:
|
||||||
|
cfg = {}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
result: set[str] = set()
|
||||||
|
for cmd in gated:
|
||||||
|
val: Any = cfg
|
||||||
|
for key in cmd.gateway_config_gate.split("."):
|
||||||
|
if isinstance(val, dict):
|
||||||
|
val = val.get(key)
|
||||||
|
else:
|
||||||
|
val = None
|
||||||
|
break
|
||||||
|
if val:
|
||||||
|
result.add(cmd.name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
|
||||||
|
"""Check if *cmd* should appear in gateway surfaces (help, menus, mappings).
|
||||||
|
|
||||||
|
Unconditionally available when ``cli_only`` is False. When ``cli_only``
|
||||||
|
is True but ``gateway_config_gate`` is set, the command is available only
|
||||||
|
when the config value is truthy. Pass *config_overrides* (from
|
||||||
|
``_resolve_config_gates()``) to avoid re-reading config for every command.
|
||||||
|
"""
|
||||||
|
if not cmd.cli_only:
|
||||||
|
return True
|
||||||
|
if cmd.gateway_config_gate:
|
||||||
|
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
|
||||||
|
return cmd.name in overrides
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def gateway_help_lines() -> list[str]:
|
def gateway_help_lines() -> list[str]:
|
||||||
"""Generate gateway help text lines from the registry."""
|
"""Generate gateway help text lines from the registry."""
|
||||||
|
overrides = _resolve_config_gates()
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if not _is_gateway_available(cmd, overrides):
|
||||||
continue
|
continue
|
||||||
args = f" {cmd.args_hint}" if cmd.args_hint else ""
|
args = f" {cmd.args_hint}" if cmd.args_hint else ""
|
||||||
alias_parts: list[str] = []
|
alias_parts: list[str] = []
|
||||||
@@ -293,9 +351,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
|||||||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
||||||
canonical command.
|
canonical command.
|
||||||
"""
|
"""
|
||||||
|
overrides = _resolve_config_gates()
|
||||||
result: list[tuple[str, str]] = []
|
result: list[tuple[str, str]] = []
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if not _is_gateway_available(cmd, overrides):
|
||||||
continue
|
continue
|
||||||
tg_name = cmd.name.replace("-", "_")
|
tg_name = cmd.name.replace("-", "_")
|
||||||
result.append((tg_name, cmd.description))
|
result.append((tg_name, cmd.description))
|
||||||
@@ -308,9 +367,10 @@ def slack_subcommand_map() -> dict[str, str]:
|
|||||||
Maps both canonical names and aliases so /hermes bg do stuff works
|
Maps both canonical names and aliases so /hermes bg do stuff works
|
||||||
the same as /hermes background do stuff.
|
the same as /hermes background do stuff.
|
||||||
"""
|
"""
|
||||||
|
overrides = _resolve_config_gates()
|
||||||
mapping: dict[str, str] = {}
|
mapping: dict[str, str] = {}
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if not _is_gateway_available(cmd, overrides):
|
||||||
continue
|
continue
|
||||||
mapping[cmd.name] = f"/{cmd.name}"
|
mapping[cmd.name] = f"/{cmd.name}"
|
||||||
for alias in cmd.aliases:
|
for alias in cmd.aliases:
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ DEFAULT_CONFIG = {
|
|||||||
"streaming": False,
|
"streaming": False,
|
||||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||||
"skin": "default",
|
"skin": "default",
|
||||||
|
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||||
},
|
},
|
||||||
|
|
||||||
# Privacy settings
|
# Privacy settings
|
||||||
|
|||||||
146
tests/gateway/test_verbose_command.py
Normal file
146
tests/gateway/test_verbose_command.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for gateway /verbose command (config-gated tool progress cycling)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import gateway.run as gateway_run
|
||||||
|
from gateway.config import Platform
|
||||||
|
from gateway.platforms.base import MessageEvent
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(text="/verbose", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
|
||||||
|
"""Build a MessageEvent for testing."""
|
||||||
|
source = SessionSource(
|
||||||
|
platform=platform,
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_name="testuser",
|
||||||
|
)
|
||||||
|
return MessageEvent(text=text, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner():
|
||||||
|
"""Create a bare GatewayRunner without calling __init__."""
|
||||||
|
runner = object.__new__(gateway_run.GatewayRunner)
|
||||||
|
runner.adapters = {}
|
||||||
|
runner._ephemeral_system_prompt = ""
|
||||||
|
runner._prefill_messages = []
|
||||||
|
runner._reasoning_config = None
|
||||||
|
runner._show_reasoning = False
|
||||||
|
runner._provider_routing = {}
|
||||||
|
runner._fallback_model = None
|
||||||
|
runner._running_agents = {}
|
||||||
|
runner.hooks = MagicMock()
|
||||||
|
runner.hooks.emit = AsyncMock()
|
||||||
|
runner.hooks.loaded_hooks = []
|
||||||
|
runner._session_db = None
|
||||||
|
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerboseCommand:
|
||||||
|
"""Tests for _handle_verbose_command in the gateway."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_by_default(self, tmp_path, monkeypatch):
|
||||||
|
"""When tool_progress_command is false, /verbose returns an info message."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
config_path = hermes_home / "config.yaml"
|
||||||
|
config_path.write_text("display:\n tool_progress: all\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||||
|
|
||||||
|
runner = _make_runner()
|
||||||
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
|
||||||
|
assert "not enabled" in result.lower()
|
||||||
|
assert "tool_progress_command" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enabled_cycles_mode(self, tmp_path, monkeypatch):
|
||||||
|
"""When enabled, /verbose cycles tool_progress mode."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
config_path = hermes_home / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"display:\n tool_progress_command: true\n tool_progress: all\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||||
|
|
||||||
|
runner = _make_runner()
|
||||||
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
|
||||||
|
# all -> verbose
|
||||||
|
assert "VERBOSE" in result
|
||||||
|
|
||||||
|
# Verify config was saved
|
||||||
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["display"]["tool_progress"] == "verbose"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
|
||||||
|
"""Calling /verbose repeatedly cycles through all four modes."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
config_path = hermes_home / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"display:\n tool_progress_command: true\n tool_progress: 'off'\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||||
|
runner = _make_runner()
|
||||||
|
|
||||||
|
# off -> new -> all -> verbose -> off
|
||||||
|
expected = ["new", "all", "verbose", "off"]
|
||||||
|
for mode in expected:
|
||||||
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["display"]["tool_progress"] == mode, \
|
||||||
|
f"Expected {mode}, got {saved['display']['tool_progress']}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
|
||||||
|
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
config_path = hermes_home / "config.yaml"
|
||||||
|
config_path.write_text(
|
||||||
|
"display:\n tool_progress_command: true\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||||
|
|
||||||
|
runner = _make_runner()
|
||||||
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
|
||||||
|
# default "all" -> verbose
|
||||||
|
assert "VERBOSE" in result
|
||||||
|
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["display"]["tool_progress"] == "verbose"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):
|
||||||
|
"""When config.yaml doesn't exist, command reports disabled."""
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
# No config.yaml
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||||
|
|
||||||
|
runner = _make_runner()
|
||||||
|
result = await runner._handle_verbose_command(_make_event())
|
||||||
|
assert "not enabled" in result.lower()
|
||||||
|
|
||||||
|
def test_verbose_is_in_gateway_known_commands(self):
|
||||||
|
"""The /verbose command is recognized by the gateway dispatch."""
|
||||||
|
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
|
||||||
|
assert "verbose" in GATEWAY_KNOWN_COMMANDS
|
||||||
@@ -134,12 +134,19 @@ class TestDerivedDicts:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestGatewayKnownCommands:
|
class TestGatewayKnownCommands:
|
||||||
def test_excludes_cli_only(self):
|
def test_excludes_cli_only_without_config_gate(self):
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||||
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
|
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
|
||||||
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
|
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
|
||||||
|
|
||||||
|
def test_includes_config_gated_cli_only(self):
|
||||||
|
"""Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS."""
|
||||||
|
for cmd in COMMAND_REGISTRY:
|
||||||
|
if cmd.gateway_config_gate:
|
||||||
|
assert cmd.name in GATEWAY_KNOWN_COMMANDS, \
|
||||||
|
f"config-gated command '{cmd.name}' should be in GATEWAY_KNOWN_COMMANDS"
|
||||||
|
|
||||||
def test_includes_gateway_commands(self):
|
def test_includes_gateway_commands(self):
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if not cmd.cli_only:
|
if not cmd.cli_only:
|
||||||
@@ -160,11 +167,11 @@ class TestGatewayHelpLines:
|
|||||||
lines = gateway_help_lines()
|
lines = gateway_help_lines()
|
||||||
assert len(lines) > 10
|
assert len(lines) > 10
|
||||||
|
|
||||||
def test_excludes_cli_only_commands(self):
|
def test_excludes_cli_only_commands_without_config_gate(self):
|
||||||
lines = gateway_help_lines()
|
lines = gateway_help_lines()
|
||||||
joined = "\n".join(lines)
|
joined = "\n".join(lines)
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||||
assert f"`/{cmd.name}" not in joined, \
|
assert f"`/{cmd.name}" not in joined, \
|
||||||
f"cli_only command /{cmd.name} should not be in gateway help"
|
f"cli_only command /{cmd.name} should not be in gateway help"
|
||||||
|
|
||||||
@@ -188,10 +195,10 @@ class TestTelegramBotCommands:
|
|||||||
for name, _ in telegram_bot_commands():
|
for name, _ in telegram_bot_commands():
|
||||||
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
|
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
|
||||||
|
|
||||||
def test_excludes_cli_only(self):
|
def test_excludes_cli_only_without_config_gate(self):
|
||||||
names = {name for name, _ in telegram_bot_commands()}
|
names = {name for name, _ in telegram_bot_commands()}
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||||
tg_name = cmd.name.replace("-", "_")
|
tg_name = cmd.name.replace("-", "_")
|
||||||
assert tg_name not in names
|
assert tg_name not in names
|
||||||
|
|
||||||
@@ -211,13 +218,84 @@ class TestSlackSubcommandMap:
|
|||||||
assert "bg" in mapping
|
assert "bg" in mapping
|
||||||
assert "reset" in mapping
|
assert "reset" in mapping
|
||||||
|
|
||||||
def test_excludes_cli_only(self):
|
def test_excludes_cli_only_without_config_gate(self):
|
||||||
mapping = slack_subcommand_map()
|
mapping = slack_subcommand_map()
|
||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if cmd.cli_only:
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||||
assert cmd.name not in mapping
|
assert cmd.name not in mapping
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config-gated gateway commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGatewayConfigGate:
|
||||||
|
"""Tests for the gateway_config_gate mechanism on CommandDef."""
|
||||||
|
|
||||||
|
def test_verbose_has_config_gate(self):
|
||||||
|
cmd = resolve_command("verbose")
|
||||||
|
assert cmd is not None
|
||||||
|
assert cmd.cli_only is True
|
||||||
|
assert cmd.gateway_config_gate == "display.tool_progress_command"
|
||||||
|
|
||||||
|
def test_verbose_in_gateway_known_commands(self):
|
||||||
|
"""Config-gated commands are always recognized by the gateway."""
|
||||||
|
assert "verbose" in GATEWAY_KNOWN_COMMANDS
|
||||||
|
|
||||||
|
def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch):
|
||||||
|
"""When the config gate is falsy, the command should not appear in help."""
|
||||||
|
# Write a config with the gate off (default)
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
lines = gateway_help_lines()
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
assert "`/verbose" not in joined
|
||||||
|
|
||||||
|
def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch):
|
||||||
|
"""When the config gate is truthy, the command should appear in help."""
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
lines = gateway_help_lines()
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
assert "`/verbose" in joined
|
||||||
|
|
||||||
|
def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
names = {name for name, _ in telegram_bot_commands()}
|
||||||
|
assert "verbose" not in names
|
||||||
|
|
||||||
|
def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
names = {name for name, _ in telegram_bot_commands()}
|
||||||
|
assert "verbose" in names
|
||||||
|
|
||||||
|
def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
mapping = slack_subcommand_map()
|
||||||
|
assert "verbose" not in mapping
|
||||||
|
|
||||||
|
def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
mapping = slack_subcommand_map()
|
||||||
|
assert "verbose" in mapping
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Autocomplete (SlashCommandCompleter)
|
# Autocomplete (SlashCommandCompleter)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||||||
| `/provider` | Show available providers and current provider |
|
| `/provider` | Show available providers and current provider |
|
||||||
| `/prompt` | View/set custom system prompt |
|
| `/prompt` | View/set custom system prompt |
|
||||||
| `/personality` | Set a predefined personality |
|
| `/personality` | Set a predefined personality |
|
||||||
| `/verbose` | Cycle tool progress display: off → new → all → verbose |
|
| `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |
|
||||||
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
|
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
|
||||||
| `/skin` | Show or change the display skin/theme |
|
| `/skin` | Show or change the display skin/theme |
|
||||||
| `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). |
|
| `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). |
|
||||||
@@ -125,7 +125,8 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/verbose`, `/statusbar`, and `/plugins` are **CLI-only** commands.
|
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/statusbar`, and `/plugins` are **CLI-only** commands.
|
||||||
|
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||||
- `/status`, `/sethome`, `/update`, `/approve`, and `/deny` are **messaging-only** commands.
|
- `/status`, `/sethome`, `/update`, `/approve`, and `/deny` are **messaging-only** commands.
|
||||||
- `/background`, `/voice`, `/reload-mcp`, and `/rollback` work in **both** the CLI and the messaging gateway.
|
- `/background`, `/voice`, `/reload-mcp`, and `/rollback` work in **both** the CLI and the messaging gateway.
|
||||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ The CLI shows animated feedback as the agent works:
|
|||||||
┊ 📄 web_extract (2.1s)
|
┊ 📄 web_extract (2.1s)
|
||||||
```
|
```
|
||||||
|
|
||||||
Cycle through display modes with `/verbose`: `off → new → all → verbose`.
|
Cycle through display modes with `/verbose`: `off → new → all → verbose`. This command can also be enabled for messaging platforms — see [configuration](/docs/user-guide/configuration#display-settings).
|
||||||
|
|
||||||
## Session Management
|
## Session Management
|
||||||
|
|
||||||
|
|||||||
@@ -1163,6 +1163,7 @@ This controls both the `text_to_speech` tool and spoken replies in voice mode (`
|
|||||||
```yaml
|
```yaml
|
||||||
display:
|
display:
|
||||||
tool_progress: all # off | new | all | verbose
|
tool_progress: all # off | new | all | verbose
|
||||||
|
tool_progress_command: false # Enable /verbose slash command in messaging gateway
|
||||||
skin: default # Built-in or custom CLI skin (see user-guide/features/skins)
|
skin: default # Built-in or custom CLI skin (see user-guide/features/skins)
|
||||||
theme_mode: auto # auto | light | dark — color scheme for skin-aware rendering
|
theme_mode: auto # auto | light | dark — color scheme for skin-aware rendering
|
||||||
personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries
|
personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries
|
||||||
@@ -1194,6 +1195,8 @@ This works with any skin — built-in or custom. Skin authors can provide `color
|
|||||||
| `all` | Every tool call with a short preview (default) |
|
| `all` | Every tool call with a short preview (default) |
|
||||||
| `verbose` | Full args, results, and debug logs |
|
| `verbose` | Full args, results, and debug logs |
|
||||||
|
|
||||||
|
In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ The handler receives the argument string (everything after `/greet`) and returns
|
|||||||
| `aliases` | Tuple of alternative names |
|
| `aliases` | Tuple of alternative names |
|
||||||
| `cli_only` | Only available in CLI |
|
| `cli_only` | Only available in CLI |
|
||||||
| `gateway_only` | Only available in messaging platforms |
|
| `gateway_only` | Only available in messaging platforms |
|
||||||
|
| `gateway_config_gate` | Config dotpath (e.g. `"display.my_option"`). When set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. |
|
||||||
|
|
||||||
## Managing plugins
|
## Managing plugins
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ Control how much tool activity is displayed in `~/.hermes/config.yaml`:
|
|||||||
```yaml
|
```yaml
|
||||||
display:
|
display:
|
||||||
tool_progress: all # off | new | all | verbose
|
tool_progress: all # off | new | all | verbose
|
||||||
|
tool_progress_command: false # set to true to enable /verbose in messaging
|
||||||
```
|
```
|
||||||
|
|
||||||
When enabled, the bot sends status messages as it works:
|
When enabled, the bot sends status messages as it works:
|
||||||
|
|||||||
Reference in New Issue
Block a user