Skip to content
Closed
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
24 changes: 24 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7827,6 +7827,30 @@ def set_config_value(key: str, value: str):
value = int(value)
elif value.replace('.', '', 1).isdigit():
value = float(value)
elif value.lstrip()[:1] in ('[', '{'):
# List/mapping literals — e.g.
# hermes config set platform_toolsets.line '["file","web"]'
# Before this branch such values were stored as a raw STRING, and every
# reader that gates on isinstance(..., list) (``_get_platform_tools``,
# ``_get_enabled_set``, ...) silently ignored them and fell back to its
# default — the setting looked saved but never took effect.
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, (list, dict)):
value = parsed
else:
print(
f"Warning: value for '{key}' looks like a list/mapping but "
f"parsed as {type(parsed).__name__}; storing as string.",
file=sys.stderr,
)
except yaml.YAMLError:
print(
f"Warning: value for '{key}' looks like a list/mapping but is "
f"not valid YAML/JSON; storing as string. Most isinstance-gated "
f"readers will ignore a string here.",
file=sys.stderr,
)

_set_nested(user_config, key, value)
# Normalize the api_base → base_url alias at set-time too (issue #8919),
Expand Down
61 changes: 61 additions & 0 deletions hermes_cli/plugins_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,57 @@ def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None:
save_config(config)


# Plugin kinds that the general loader does NOT gate on plugins.enabled /
# plugins.disabled: model providers register through providers/__init__.py's
# own discovery (selected via `hermes model` / model.provider), and exclusive
# category plugins (memory providers) activate via `<category>.provider`.
# `plugins enable`/`disable` used to accept these and print a success message
# while having zero effect — the flag was written but nothing ever read it,
# which misled users into thinking they had switched something on or off.
_PASSIVE_PLUGIN_KINDS = {"model-provider", "exclusive"}


def _plugin_kind(key: str) -> str:
"""Manifest ``kind`` for a discovered plugin key (default: ``standalone``)."""
for entry in _discover_all_plugins():
# entry = (name, version, description, source, dir_path, key)
if entry[5] == key:
d = Path(entry[4])
for fname in ("plugin.yaml", "plugin.yml"):
mf = d / fname
if mf.exists():
try:
import yaml

data = yaml.safe_load(mf.read_text(encoding="utf-8")) or {}
return str(data.get("kind", "standalone"))
except Exception:
return "standalone"
return "standalone"


def _print_passive_kind_hint(console, key: str, kind: str) -> None:
"""Explain how a passive-kind plugin is actually controlled."""
if kind == "model-provider":
console.print(
f"[yellow]![/yellow] [bold]{key}[/bold] is a model provider — it is "
"not controlled by plugins.enabled/disabled (providers register "
"automatically at startup).\n"
" To use it: run [bold]hermes model[/bold] and pick it, or set "
"[dim]model.provider[/dim] in config.yaml.\n"
" To stop using it: select a different provider; remove its API key "
"from ~/.hermes/.env to make it unselectable.\n"
"Nothing was changed."
)
else: # exclusive
console.print(
f"[yellow]![/yellow] [bold]{key}[/bold] is an exclusive category "
"plugin — it is activated by its category's provider key (e.g. "
"[dim]memory.provider[/dim]), not by plugins.enabled/disabled.\n"
"Nothing was changed."
)


def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
"""Add a plugin to the enabled allow-list (and remove it from disabled).

Expand All @@ -831,6 +882,11 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
sys.exit(1)
key, source = resolved

kind = _plugin_kind(key)
if kind in _PASSIVE_PLUGIN_KINDS:
_print_passive_kind_hint(console, key, kind)
return

enabled = _get_enabled_set()
disabled = _get_disabled_set()

Expand Down Expand Up @@ -910,6 +966,11 @@ def cmd_disable(name: str) -> None:
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)

kind = _plugin_kind(key)
if kind in _PASSIVE_PLUGIN_KINDS:
_print_passive_kind_hint(console, key, kind)
return

enabled = _get_enabled_set()
disabled = _get_disabled_set()

Expand Down
12 changes: 6 additions & 6 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2402,9 +2402,9 @@ def update_token_counts(
cost_status = COALESCE(?, cost_status),
cost_source = COALESCE(?, cost_source),
pricing_version = COALESCE(?, pricing_version),
billing_provider = COALESCE(?, billing_provider),
billing_base_url = COALESCE(?, billing_base_url),
billing_mode = COALESCE(?, billing_mode),
billing_provider = COALESCE(billing_provider, ?),
billing_base_url = COALESCE(billing_base_url, ?),
billing_mode = COALESCE(billing_mode, ?),
model = COALESCE(?, model),
api_call_count = ?
WHERE id = ?"""
Expand All @@ -2423,9 +2423,9 @@ def update_token_counts(
cost_status = COALESCE(?, cost_status),
cost_source = COALESCE(?, cost_source),
pricing_version = COALESCE(?, pricing_version),
billing_provider = COALESCE(?, billing_provider),
billing_base_url = COALESCE(?, billing_base_url),
billing_mode = COALESCE(?, billing_mode),
billing_provider = COALESCE(billing_provider, ?),
billing_base_url = COALESCE(billing_base_url, ?),
billing_mode = COALESCE(billing_mode, ?),
model = COALESCE(?, model),
api_call_count = COALESCE(api_call_count, 0) + ?
WHERE id = ?"""
Expand Down
71 changes: 71 additions & 0 deletions tests/hermes_cli/test_config_set_list_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""``hermes config set`` must parse list/mapping literals, not store them as strings.

Before this fix, ``hermes config set platform_toolsets.discord '["file","web"]'``
stored the value as a raw STRING. Every reader that gates on
``isinstance(..., list)`` — ``_get_platform_tools``, ``_get_enabled_set``,
``_get_disabled_set`` — then silently ignored it and fell back to its default,
so the setting looked saved but never took effect (observed in the wild as a
platform running on the wrong toolset bundle for weeks).
"""
import pytest


@pytest.fixture
def user_home(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_MANAGED_DIR", raising=False)
import hermes_cli.config as cfg
from hermes_cli import managed_scope

cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
managed_scope.invalidate_managed_cache()
return home


def test_list_literal_is_parsed_to_list(user_home):
from hermes_cli.config import set_config_value, read_raw_config

set_config_value("platform_toolsets.line", '["clarify", "file", "web"]')
raw = read_raw_config()
assert raw["platform_toolsets"]["line"] == ["clarify", "file", "web"]


def test_mapping_literal_is_parsed_to_dict(user_home):
from hermes_cli.config import set_config_value, read_raw_config

set_config_value("display.tool_progress_overrides", '{"terminal": "off"}')
raw = read_raw_config()
assert raw["display"]["tool_progress_overrides"] == {"terminal": "off"}


def test_yaml_flow_list_is_parsed(user_home):
from hermes_cli.config import set_config_value, read_raw_config

set_config_value("plugins.enabled", "[model-providers/gemini]")
raw = read_raw_config()
assert raw["plugins"]["enabled"] == ["model-providers/gemini"]


def test_invalid_list_literal_warns_and_stores_string(user_home, capsys):
from hermes_cli.config import set_config_value, read_raw_config

set_config_value("platform_toolsets.line", '["unclosed')
captured = capsys.readouterr()
assert "not valid" in captured.err.lower() or "warning" in captured.err.lower()
raw = read_raw_config()
assert raw["platform_toolsets"]["line"] == '["unclosed'


def test_scalar_values_unaffected(user_home):
from hermes_cli.config import set_config_value, read_raw_config

set_config_value("agent.max_turns", "300")
set_config_value("display.compact", "true")
set_config_value("tts.provider", "edge")
raw = read_raw_config()
assert raw["agent"]["max_turns"] == 300
assert raw["display"]["compact"] is True
assert raw["tts"]["provider"] == "edge"
91 changes: 91 additions & 0 deletions tests/hermes_cli/test_plugins_enable_passive_kinds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""``hermes plugins enable/disable`` must not silently no-op on passive kinds.

Model providers (``kind: model-provider``) register through
``providers/__init__.py``'s own discovery and are selected via ``hermes model``
/ ``model.provider``; exclusive plugins (``kind: exclusive``, e.g. memory
providers) activate via ``<category>.provider``. The general plugin loader
skips both kinds, so a ``plugins.enabled``/``disabled`` entry for them is dead
config. Before this fix, ``hermes plugins enable gemini`` printed a green
success message while changing nothing that any loader would ever read.
"""
import pytest


@pytest.fixture
def fake_plugins(tmp_path, monkeypatch):
"""A discovery view with one plugin per kind, backed by real manifests."""
import hermes_cli.plugins_cmd as pc

entries = []
for name, kind in [
("gemini", "model-provider"),
("honcho", "exclusive"),
("nemo_relay", "standalone"),
]:
d = tmp_path / name
d.mkdir()
(d / "plugin.yaml").write_text(
f"name: {name}\nkind: {kind}\nversion: 1.0.0\n", encoding="utf-8"
)
key = f"model-providers/{name}" if kind == "model-provider" else name
entries.append((name, "1.0.0", "desc", "bundled", str(d), key))

monkeypatch.setattr(pc, "_discover_all_plugins", lambda: entries)
# config writes must land in a scratch HERMES_HOME
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
import hermes_cli.config as cfg

cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
return pc


def _enabled_disabled(pc):
return pc._get_enabled_set(), pc._get_disabled_set()


def test_enable_model_provider_is_refused_with_hint(fake_plugins, capsys):
pc = fake_plugins
pc.cmd_enable("gemini")
out = capsys.readouterr().out
assert "model provider" in out
assert "hermes model" in out
assert "Nothing was changed" in out
enabled, disabled = _enabled_disabled(pc)
assert "model-providers/gemini" not in enabled
assert "model-providers/gemini" not in disabled


def test_disable_model_provider_is_refused_with_hint(fake_plugins, capsys):
pc = fake_plugins
pc.cmd_disable("gemini")
out = capsys.readouterr().out
assert "model provider" in out
enabled, disabled = _enabled_disabled(pc)
assert "model-providers/gemini" not in disabled


def test_enable_exclusive_is_refused_with_hint(fake_plugins, capsys):
pc = fake_plugins
pc.cmd_enable("honcho")
out = capsys.readouterr().out
assert "exclusive" in out
assert "provider" in out
enabled, _ = _enabled_disabled(pc)
assert "honcho" not in enabled


def test_enable_standalone_still_works(fake_plugins, capsys):
pc = fake_plugins
pc.cmd_enable("nemo_relay", allow_tool_override=False)
out = capsys.readouterr().out
assert "enabled" in out
enabled, _ = _enabled_disabled(pc)
assert "nemo_relay" in enabled


def test_plugin_kind_defaults_to_standalone(fake_plugins):
pc = fake_plugins
assert pc._plugin_kind("no-such-key") == "standalone"
11 changes: 9 additions & 2 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2367,7 +2367,7 @@ def _load_show_reasoning() -> bool:
return bool((_load_cfg().get("display") or {}).get("show_reasoning", False))


def _load_memory_notifications() -> str:
def _load_memory_notifications(platform_key: str = "cli") -> str:
"""Self-improvement review notification mode from config.yaml.

Parity with the messaging gateway (``gateway/run.py``) and the classic CLI:
Expand All @@ -2376,8 +2376,15 @@ def _load_memory_notifications() -> str:
TUI/desktop backend always behaved as ``"on"`` and silently ignored a user
who set ``off``. Accepts ``off`` / ``on`` (default) / ``verbose``; a bool is
normalized for back-compat.

Resolved through ``resolve_display_setting`` so a per-platform override
(``display.platforms.<platform>.memory_notifications``) wins over the global
``display.memory_notifications``, matching the messaging gateway. The TUI is
a single ``cli``-tier surface, so ``platform_key`` defaults to ``"cli"``.
"""
raw = (_load_cfg().get("display") or {}).get("memory_notifications")
from gateway.display_config import resolve_display_setting

raw = resolve_display_setting(_load_cfg(), platform_key, "memory_notifications", "on")
if isinstance(raw, bool):
return "on" if raw else "off"
return str(raw).lower() if raw else "on"
Expand Down