{"repo":"NousResearch/hermes-agent","public_id":"5590","display_label":"Bug fixes: subagent toolset, config, session, writes","summary":"A batch of core bug fixes covering multiple areas including subagent toolset inheritance when the parent's enabled_toolsets is None, model configuration, session reset, alias fallback, launchctl, and atomic writes. The subagent toolset inheritance issue is represented by both a report and a corresponding fix.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5630,"merged_at":"2026-04-06T20:20:01+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-06T20:20:01+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":5590,"pr_number":5630,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-06T20:20:01+00:00"}],"members":[{"kind":"issue","number":5590,"title":"Subagent toolset inheritance fails when parent enabled_toolsets is None","url":null,"state":"closed"},{"kind":"pr","number":5591,"title":"fix: subagent toolset inheritance when parent enabled_toolsets is None","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5630","number":5630}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5630,"title":"fix: batch core bug fixes — model config, session reset, alias fallback, launchctl, delegation, atomic writes","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/channel_directory.py","gateway/run.py","hermes_cli/gateway.py","hermes_cli/model_switch.py","tests/gateway/test_channel_directory.py","tests/gateway/test_session_model_reset.py","tests/hermes_cli/test_gateway_service.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -3721,7 +3721,7 @@ def _handle_model_switch(self, cmd_original: str):\n \n         # Persistence\n         if persist_global:\n-            save_config_value(\"model.name\", result.new_model)\n+            save_config_value(\"model.default\", result.new_model)\n             if result.provider_changed:\n                 save_config_value(\"model.provider\", result.target_provider)\n             _cprint(\"    Saved to config.yaml (--global)\")"]},{"file":"gateway/channel_directory.py","hunks":["@@ -12,6 +12,7 @@\n from typing import Any, Dict, List, Optional\n \n from hermes_cli.config import get_hermes_home\n+from utils import atomic_json_write\n \n logger = logging.getLogger(__name__)\n ","@@ -86,9 +87,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:\n     }\n \n     try:\n-        DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True)\n-        with open(DIRECTORY_PATH, \"w\", encoding=\"utf-8\") as f:\n-            json.dump(directory, f, indent=2, ensure_ascii=False)\n+        atomic_json_write(DIRECTORY_PATH, directory)\n     except Exception as e:\n         logger.warning(\"Channel directory: failed to write: %s\", e)\n "]},{"file":"gateway/run.py","hunks":["@@ -3267,6 +3267,10 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:\n         # Reset the session\n         new_entry = self.session_store.reset_session(session_key)\n \n+        # Clear any session-scoped model override so the next agent picks up\n+        # the configured default instead of the previously switched model.\n+        self._session_model_overrides.pop(session_key, None)\n+\n         # Emit session:end hook (session is ending)\n         await self.hooks.emit(\"session:end\", {\n             \"platform\": source.platform.value if source.platform else \"\",","@@ -3493,7 +3497,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str:\n                     cfg = yaml.safe_load(f) or {}\n                 model_cfg = cfg.get(\"model\", {})\n                 if isinstance(model_cfg, dict):\n-                    current_model = model_cfg.get(\"name\", \"\")\n+                    current_model = model_cfg.get(\"default\", \"\")\n                     current_provider = model_cfg.get(\"provider\", current_provider)\n                     current_base_url = model_cfg.get(\"base_url\", \"\")\n                 user_provs = cfg.get(\"providers\")","@@ -3603,7 +3607,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str:\n                 else:\n                     cfg = {}\n                 model_cfg = cfg.setdefault(\"model\", {})\n-                model_cfg[\"name\"] = result.new_model\n+                model_cfg[\"default\"] = result.new_model\n                 model_cfg[\"provider\"] = result.target_provider\n                 if result.base_url:\n                     model_cfg[\"base_url\"] = result.base_url"]},{"file":"hermes_cli/gateway.py","hunks":["@@ -1121,7 +1121,7 @@ def launchd_start():\n     try:\n         subprocess.run([\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"], check=True, timeout=30)\n     except subprocess.CalledProcessError as e:\n-        if e.returncode != 3:\n+        if e.returncode not in (3, 113):\n             raise\n         print(\"↻ launchd job was unloaded; reloading service definition\")\n         subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=True, timeout=30)","@@ -1183,7 +1183,7 @@ def launchd_restart():\n         subprocess.run([\"launchctl\", \"kickstart\", \"-k\", target], check=True, timeout=90)\n         print(\"✓ Service restarted\")\n     except subprocess.CalledProcessError as e:\n-        if e.returncode != 3:\n+        if e.returncode not in (3, 113):\n             raise\n         # Job not loaded — bootstrap and start fresh\n         print(\"↻ launchd job was unloaded; reloading\")"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -339,12 +339,37 @@ def resolve_alias(\n     return None\n \n \n+def get_authenticated_provider_slugs(\n+    current_provider: str = \"\",\n+    user_providers: dict = None,\n+) -> list[str]:\n+    \"\"\"Return slugs of providers that have credentials.\n+\n+    Uses ``list_authenticated_providers()`` which is backed by the models.dev\n+    in-memory cache (1 hr TTL) — no extra network cost.\n+    \"\"\"\n+    try:\n+        providers = list_authenticated_providers(\n+            current_provider=current_provider,\n+            user_providers=user_providers,\n+            max_models=0,\n+        )\n+        return [p[\"slug\"] for p in providers]\n+    except Exception:\n+        return []\n+\n+\n def _resolve_alias_fallback(\n     raw_input: str,\n-    fallback_providers: tuple[str, ...] = (\"openrouter\", \"nous\"),\n+    authenticated_providers: list[str] = (),\n ) -> Optional[tuple[str, str, str]]:\n-    \"\"\"Try to resolve an alias on fallback providers.\"\"\"\n-    for provider in fallback_providers:\n+    \"\"\"Try to resolve an alias on the user's authenticated providers.\n+\n+    Falls back to ``(\"openrouter\", \"nous\")`` only when no authenticated\n+    providers are supplied (backwards compat for non-interactive callers).\n+    \"\"\"\n+    providers = authenticated_providers or (\"openrouter\", \"nous\")\n+    for provider in providers:\n         result = resolve_alias(raw_input, provider)\n         if result is not None:\n             return result","@@ -494,7 +519,11 @@ def switch_model(\n             # --- Step b: Alias exists but not on current provider -> fallback ---\n             key = raw_input.strip().lower()\n             if key in MODEL_ALIASES:\n-                fallback_result = _resolve_alias_fallback(raw_input)\n+                authed = get_authenticated_provider_slugs(\n+                    current_provider=current_provider,\n+                    user_providers=user_providers,\n+                )\n+                fallback_result = _resolve_alias_fallback(raw_input, authed)\n                 if fallback_result is not None:\n                     target_provider, new_model, resolved_alias = fallback_result\n                     logger.debug("]},{"file":"tests/gateway/test_channel_directory.py","hunks":["@@ -6,6 +6,7 @@\n from unittest.mock import patch\n \n from gateway.channel_directory import (\n+    build_channel_directory,\n     resolve_channel_name,\n     format_directory_for_display,\n     load_directory,","@@ -45,6 +46,27 @@ def test_corrupt_file(self, tmp_path):\n         assert result[\"updated_at\"] is None\n \n \n+class TestBuildChannelDirectoryWrites:\n+    def test_failed_write_preserves_previous_cache(self, tmp_path, monkeypatch):\n+        cache_file = _write_directory(tmp_path, {\n+            \"telegram\": [{\"id\": \"123\", \"name\": \"Alice\", \"type\": \"dm\"}]\n+        })\n+        previous = json.loads(cache_file.read_text())\n+\n+        def broken_dump(data, fp, *args, **kwargs):\n+            fp.write('{\"updated_at\":')\n+            fp.flush()\n+            raise OSError(\"disk full\")\n+\n+        monkeypatch.setattr(json, \"dump\", broken_dump)\n+\n+        with patch(\"gateway.channel_directory.DIRECTORY_PATH\", cache_file):\n+            build_channel_directory({})\n+            result = load_directory()\n+\n+        assert result == previous\n+\n+\n class TestResolveChannelName:\n     def _setup(self, tmp_path, platforms):\n         cache_file = _write_directory(tmp_path, platforms)"]},{"file":"tests/gateway/test_session_model_reset.py","hunks":["@@ -0,0 +1,126 @@\n+\"\"\"Tests that /new (and its /reset alias) clears the session-scoped model override.\"\"\"\n+from datetime import datetime\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource, build_session_key\n+\n+\n+def _make_source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        user_id=\"u1\",\n+        chat_id=\"c1\",\n+        user_name=\"tester\",\n+        chat_type=\"dm\",\n+    )\n+\n+\n+def _make_event(text: str) -> MessageEvent:\n+    return MessageEvent(text=text, source=_make_source(), message_id=\"m1\")\n+\n+\n+def _make_runner():\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    adapter = MagicMock()\n+    adapter.send = AsyncMock()\n+    runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner._voice_mode = {}\n+    runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)\n+    runner._session_model_overrides = {}\n+    runner._pending_model_notes = {}\n+    runner._background_tasks = set()\n+\n+    session_key = build_session_key(_make_source())\n+    session_entry = SessionEntry(\n+        session_key=session_key,\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.reset_session.return_value = session_entry\n+    runner.session_store._entries = {session_key: session_entry}\n+    runner.session_store._generate_session_key.return_value = session_key\n+    runner._running_agents = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._session_db = None\n+    runner._agent_cache_lock = None  # disables _evict_cached_agent lock path\n+    runner._is_user_authorized = lambda _source: True\n+    runner._format_session_info = lambda: \"\"\n+\n+    return runner\n+\n+\n[redacted:email]\n+async def test_new_command_clears_session_model_override():\n+    \"\"\"/new must remove the session-scoped model override for that session.\"\"\"\n+    runner = _make_runner()\n+    session_key = build_session_key(_make_source())\n+\n+    # Simulate a prior /model switch stored as a session override\n+    runner._session_model_overrides[session_key] = {\n+        \"model\": \"gpt-4o\",\n+        \"provider\": \"openai\",\n+        \"api_key\": \"[redacted:secret-assignment]\",\n+        \"base_url\": \"\",\n+        \"api_mode\": \"openai\",\n+    }\n+\n+    await runner._handle_reset_command(_make_event(\"/new\"))\n+\n+    assert session_key not in runner._session_model_overrides\n+\n+\n[redacted:email]\n+async def test_new_command_no_override_is_noop():\n+    \"\"\"/new with no prior model override must not raise.\"\"\"\n+    runner = _make_runner()\n+    session_key = build_session_key(_make_source())\n+\n+    assert session_key not in runner._session_model_overrides\n+\n+    await runner._handle_reset_command(_make_event(\"/new\"))\n+\n+    assert session_key not in runner._session_model_overrides\n+\n+\n[redacted:email]\n+async def test_new_command_only_clears_own_session():\n+    \"\"\"/new must only clear the override for the session that triggered it.\"\"\"\n+    runner = _make_runner()\n+    session_key = build_session_key(_make_source())\n+    other_key = \"other_session_key\"\n+\n+    runner._session_model_overrides[session_key] = {\n+        \"model\": \"gpt-4o\",\n+        \"provider\": \"openai\",\n+        \"api_key\": \"[redacted:secret-assignment]\",\n+        \"base_url\": \"\",\n+        \"api_mode\": \"openai\",\n+    }\n+    runner._session_model_overrides[other_key] = {\n+        \"model\": \"claude-sonnet-4-6\",\n+        \"provider\": \"anthropic\",\n+        \"api_key\": \"[redacted:secret-assignment]\",\n+        \"base_url\": \"\",\n+        \"api_mode\": \"anthropic\",\n+    }\n+\n+    await runner._handle_reset_command(_make_event(\"/new\"))\n+\n+    assert session_key not in runner._session_model_overrides\n+    assert other_key in runner._session_model_overrides"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -205,6 +205,33 @@ def fake_run(cmd, check=False, **kwargs):\n             [\"launchctl\", \"kickstart\", target],\n         ]\n \n+    def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkeypatch):\n+        \"\"\"Exit code 113 (\\\"Could not find service\\\") should also trigger bootstrap recovery.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        label = gateway_cli.get_launchd_label()\n+\n+        calls = []\n+        domain = gateway_cli._launchd_domain()\n+        target = f\"{domain}/{label}\"\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            calls.append(cmd)\n+            if cmd == [\"launchctl\", \"kickstart\", target] and calls.count(cmd) == 1:\n+                raise gateway_cli.subprocess.CalledProcessError(113, cmd, stderr=\"Could not find service\")\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        gateway_cli.launchd_start()\n+\n+        assert calls == [\n+            [\"launchctl\", \"kickstart\", target],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n+            [\"launchctl\", \"kickstart\", target],\n+        ]\n+\n     def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):\n         plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n         plist_path.write_text(\"<plist>old content</plist>\", encoding=\"utf-8\")"]},{"file":"tools/delegate_tool.py","hunks":["@@ -185,12 +185,28 @@ def _build_child_agent(\n \n     # When no explicit toolsets given, inherit from parent's enabled toolsets\n     # so disabled tools (e.g. web) don't leak to subagents.\n-    parent_toolsets = set(getattr(parent_agent, \"enabled_toolsets\", None) or DEFAULT_TOOLSETS)\n+    # Note: enabled_toolsets=None means \"all tools enabled\" (the default),\n+    # so we must derive effective toolsets from the parent's loaded tools.\n+    parent_enabled = getattr(parent_agent, \"enabled_toolsets\", None)\n+    if parent_enabled is not None:\n+        parent_toolsets = set(parent_enabled)\n+    elif parent_agent and hasattr(parent_agent, \"valid_tool_names\"):\n+        # enabled_toolsets is None (all tools) — derive from loaded tool names\n+        import model_tools\n+        parent_toolsets = {\n+            ts for name in parent_agent.valid_tool_names\n+            if (ts := model_tools.get_toolset_for_tool(name)) is not None\n+        }\n+    else:\n+        parent_toolsets = set(DEFAULT_TOOLSETS)\n+\n     if toolsets:\n         # Intersect with parent — subagent must not gain tools the parent lacks\n         child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets])\n-    elif parent_agent and getattr(parent_agent, \"enabled_toolsets\", None):\n-        child_toolsets = _strip_blocked_tools(parent_agent.enabled_toolsets)\n+    elif parent_agent and parent_enabled is not None:\n+        child_toolsets = _strip_blocked_tools(parent_enabled)\n+    elif parent_toolsets:\n+        child_toolsets = _strip_blocked_tools(sorted(parent_toolsets))\n     else:\n         child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS)\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}