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
2 changes: 1 addition & 1 deletion hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -2397,7 +2397,7 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
# Extra ports detection probes for an external llama-server (besides 8080).
"detect_ports": [],
},
"_config_version": 42, # Config schema version - bump this when adding new required fields
"_config_version": 43, # Config schema version - bump this when adding new required fields
}


Expand Down
52 changes: 52 additions & 0 deletions hermes_cli/config_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,57 @@ def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None:
f"({', '.join(cleaned)}) — Bot Chat sessions now get the live roster instead.")


def _migrate_to_43(results: Dict[str, Any], quiet: bool) -> None:
# 42 → 43: turn the `connections` toolset on for every platform whose saved `platform_toolsets`
# list predates it. `hermes tools` writes an explicit list, and absence from that list reads as
# "unchecked", so a toolset that ships later stays off for picker users while composite users
# inherit it. Two "no"s are kept: a platform whose `known_builtin_toolsets` records `connections`
# saw the checkbox and left it off, and `agent.disabled_toolsets` (Blank Slate, `hermes tools
# --disable`) is subtracted last by the resolver, so appending there would claim an enable that
# never takes effect.
from agent.skill_utils import parse_config_string_list
from hermes_cli.tools_config import _configurable_keys, _get_plugin_toolset_keys
from hermes_cli.toolset_scope import toolset_allowed_for_platform

config = read_raw_config()
saved = config.get("platform_toolsets")
if not isinstance(saved, dict):
return
if "connections" in parse_config_string_list(_dict_at(config, "agent").get("disabled_toolsets")):
return
known = _dict_at(config, "known_builtin_toolsets")
# Same predicate the resolver uses to pick its explicit branch: any configurable or plugin key.
explicit_keys = _configurable_keys() | _get_plugin_toolset_keys()
enabled_for: List[str] = []
for platform, toolsets in saved.items():
if not isinstance(toolsets, list) or "connections" in toolsets:
continue
if not toolset_allowed_for_platform("connections", platform):
continue
# A composite like [hermes-cli] already inherits every core tool at read time.
if not any(str(ts) in explicit_keys for ts in toolsets):
continue
offered = known.get(platform)
if isinstance(offered, list) and "connections" in offered:
continue
saved[platform] = sorted({*map(str, toolsets), "connections"})
if isinstance(offered, list):
known[platform] = sorted({*map(str, offered), "connections"})
enabled_for.append(str(platform))
if not enabled_for:
return
config["platform_toolsets"] = saved
if known:
config["known_builtin_toolsets"] = known
platforms = ", ".join(sorted(enabled_for))
_commit(
config, results, quiet,
f"enabled the connections toolset for {platforms}",
f" ✓ Enabled the Connections toolset (Gmail, Linear, Notion, ...) for {platforms} — "
"the manage_connections tool appears when your Nous Portal account has tool access "
"(paid plan or active free pool). Uncheck Connections in `hermes tools` to turn it off.")


#: Registry of (target_version, step), strictly ascending; simple default-flip steps are
#: declared inline via _rewrite_stale_default / _rewrite_key partials. Later steps observe
#: earlier steps' writes via read_raw_config() (filesystem state). v12 is the support floor:
Expand Down Expand Up @@ -637,6 +688,7 @@ def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None:
" ✓ Removed cron.model_drift_guard — unpinned cron jobs now keep running on the "
"model/provider they were created under when the global default changes, instead "
"of being skipped. Pin a job or set cron.model to move it."))),
(43, _migrate_to_43),
)


Expand Down
144 changes: 144 additions & 0 deletions tests/hermes_cli/test_config_migration_43_connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Migration 42→43: saved ``platform_toolsets`` lists gain the ``connections`` toolset.

``hermes tools`` persists an explicit per-platform toolset list, and absence from
that list reads as "unchecked" — so a toolset that ships after the list was saved
stays off for picker users while composite (``[hermes-cli]``) users inherit it.
The 42→43 step turns ``connections`` on for stale lists, preserves an explicit
decline, and leaves composites/empty lists alone.
"""

import os
from unittest.mock import patch

import pytest
import yaml


class TestConnectionsToolsetMigration:
"""Behaviour contract for ``_migrate_to_43`` driven through ``run_migrations``."""

@staticmethod
def _run_ladder(tmp_path, current_ver=42):
from hermes_cli.config_migrations import run_migrations

results = {"env_added": [], "config_added": [], "warnings": []}
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
run_migrations(current_ver, results, quiet=True)
return results

@staticmethod
def _write_config(tmp_path, config):
(tmp_path / "config.yaml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)

@staticmethod
def _read_config(tmp_path):
return yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))

def test_stale_list_gains_connections_for_every_platform(self, tmp_path):
"""A list saved before the toolset shipped is offered it (and records the offer)."""
platforms = {
"cli": ["file", "terminal", "web"],
"telegram": ["file", "web"],
}
self._write_config(
tmp_path,
{
"_config_version": 42,
"platform_toolsets": platforms,
"known_builtin_toolsets": {
"cli": ["browser", "file", "memory", "skills", "terminal", "todo", "web"]
},
},
)

results = self._run_ladder(tmp_path)
raw = self._read_config(tmp_path)

assert "connections" in raw["platform_toolsets"]["cli"]
assert "connections" in raw["platform_toolsets"]["telegram"]
# The offer is recorded so a later uncheck is a recorded decline, not another migration.
assert "connections" in raw["known_builtin_toolsets"]["cli"]
added = [entry for entry in results["config_added"] if "connections" in entry.lower()]
assert len(added) == 1, results["config_added"]

@pytest.mark.parametrize(
"platform_toolsets, known",
[
pytest.param( # (a) the user saw the checkbox and left it off
{"cli": ["file", "terminal", "web"]},
{"cli": ["browser", "connections", "file", "web"]},
id="declined",
),
pytest.param( # (b) composite list already inherits every core tool
{"cli": ["hermes-cli"]},
{},
id="composite",
),
pytest.param( # (c) empty picker selection — no configurable key to extend
{"cli": []},
{},
id="empty",
),
],
)
def test_declines_composites_and_empty_lists_are_untouched(
self, tmp_path, platform_toolsets, known
):
self._write_config(
tmp_path,
{
"_config_version": 42,
"platform_toolsets": platform_toolsets,
"known_builtin_toolsets": known,
},
)

results = self._run_ladder(tmp_path)
raw = self._read_config(tmp_path)

assert raw["platform_toolsets"] == platform_toolsets
assert results["config_added"] == []

@pytest.mark.parametrize("disabled", [["browser", "connections", "web"], '["connections"]'], ids=["list", "json-string"])
def test_global_disable_is_not_overridden_or_claimed(self, tmp_path, disabled):
"""Blank Slate and `hermes tools --disable` write agent.disabled_toolsets, which the resolver
subtracts last; appending to the platform list would print an enable that never takes effect."""
platform_toolsets = {"cli": ["file", "skills", "terminal", "vision"]}
self._write_config(
tmp_path,
{
"_config_version": 42,
"platform_toolsets": platform_toolsets,
"agent": {"disabled_toolsets": disabled},
},
)

results = self._run_ladder(tmp_path)
raw = self._read_config(tmp_path)

assert raw["platform_toolsets"] == platform_toolsets
assert raw["agent"]["disabled_toolsets"] == disabled
assert results["config_added"] == []

def test_rerun_adds_nothing_and_keeps_one_connections(self, tmp_path):
"""Running the step twice is a no-op; the toolset appears exactly once."""
self._write_config(
tmp_path,
{
"_config_version": 42,
"platform_toolsets": {"cli": ["file", "terminal", "web"]},
"known_builtin_toolsets": {"cli": ["file", "terminal", "web"]},
},
)

self._run_ladder(tmp_path)
after_first = self._read_config(tmp_path)["platform_toolsets"]["cli"]
second = self._run_ladder(tmp_path)
after_second = self._read_config(tmp_path)["platform_toolsets"]["cli"]

# Running the step again rewrites nothing and never appends a duplicate.
assert second["config_added"] == []
assert after_second == after_first
assert after_second.count("connections") <= 1
Loading