# NousResearch/hermes-agent · Bug fixes: subagent toolset, config, session, writes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #5630. Verify it addresses your case.
- **Recommended:** read PR #5630 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #5590: Subagent toolset inheritance fails when parent enabled_toolsets is None

## Prior & competing PRs

- **#5630: fix: batch core bug fixes — model config, session reset, alias fallback, launchctl, delegation, atomic writes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -3721,7 +3721,7 @@ def _handle_model_switch(self, cmd_original: str):
     
             # Persistence
             if persist_global:
    -            save_config_value("model.name", result.new_model)
    +            save_config_value("model.default", result.new_model)
                 if result.provider_changed:
                     save_config_value("model.provider", result.target_provider)
                 _cprint("    Saved to config.yaml (--global)")
    ```
  - `gateway/channel_directory.py`

    ```diff
    @@ -12,6 +12,7 @@
     from typing import Any, Dict, List, Optional
     
     from hermes_cli.config import get_hermes_home
    +from utils import atomic_json_write
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -86,9 +87,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
         }
     
         try:
    -        DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
    -        with open(DIRECTORY_PATH, "w", encoding="utf-8") as f:
    -            json.dump(directory, f, indent=2, ensure_ascii=False)
    +        atomic_json_write(DIRECTORY_PATH, directory)
         except Exception as e:
             logger.warning("Channel directory: failed to write: %s", e)
     
    ```
  - `gateway/run.py`

    ```diff
    @@ -3267,6 +3267,10 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
             # Reset the session
             new_entry = self.session_store.reset_session(session_key)
     
    +        # Clear any session-scoped model override so the next agent picks up
    +        # the configured default instead of the previously switched model.
    +        self._session_model_overrides.pop(session_key, None)
    +
             # Emit session:end hook (session is ending)
             await self.hooks.emit("session:end", {
                 "platform": source.platform.value if source.platform else "",
    ```

    ```diff
    @@ -3493,7 +3497,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str:
                         cfg = yaml.safe_load(f) or {}
                     model_cfg = cfg.get("model", {})
                     if isinstance(model_cfg, dict):
    -                    current_model = model_cfg.get("name", "")
    +                    current_model = model_cfg.get("default", "")
                         current_provider = model_cfg.get("provider", current_provider)
                         current_base_url = model_cfg.get("base_url", "")
                     user_provs = cfg.get("providers")
    ```

    ```diff
    @@ -3603,7 +3607,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str:
                     else:
                         cfg = {}
                     model_cfg = cfg.setdefault("model", {})
    -                model_cfg["name"] = result.new_model
    +                model_cfg["default"] = result.new_model
                     model_cfg["provider"] = result.target_provider
                     if result.base_url:
                         model_cfg["base_url"] = result.base_url
    ```
  - `hermes_cli/gateway.py`

    ```diff
    @@ -1121,7 +1121,7 @@ def launchd_start():
         try:
             subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
         except subprocess.CalledProcessError as e:
    -        if e.returncode != 3:
    +        if e.returncode not in (3, 113):
                 raise
             print("↻ launchd job was unloaded; reloading service definition")
             subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
    ```

    ```diff
    @@ -1183,7 +1183,7 @@ def launchd_restart():
             subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
             print("✓ Service restarted")
         except subprocess.CalledProcessError as e:
    -        if e.returncode != 3:
    +        if e.returncode not in (3, 113):
                 raise
             # Job not loaded — bootstrap and start fresh
             print("↻ launchd job was unloaded; reloading")
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -339,12 +339,37 @@ def resolve_alias(
         return None
     
     
    +def get_authenticated_provider_slugs(
    +    current_provider: str = "",
    +    user_providers: dict = None,
    +) -> list[str]:
    +    """Return slugs of providers that have credentials.
    +
    +    Uses ``list_authenticated_providers()`` which is backed by the models.dev
    +    in-memory cache (1 hr TTL) — no extra network cost.
    +    """
    +    try:
    +        providers = list_authenticated_providers(
    +            current_provider=current_provider,
    +            user_providers=user_providers,
    +            max_models=0,
    +        )
    +        return [p["slug"] for p in providers]
    +    except Exception:
    +        return []
    +
    +
     def _resolve_alias_fallback(
         raw_input: str,
    -    fallback_providers: tuple[str, ...] = ("openrouter", "nous"),
    +    authenticated_providers: list[str] = (),
     ) -> Optional[tuple[str, str, str]]:
    -    """Try to resolve an alias on fallback providers."""
    -    for provider in fallback_providers:
    +    """Try to resolve an alias on the user's authenticated providers.
    +
    +    Falls back to ``("openrouter", "nous")`` only when no authenticated
    +    providers are supplied (backwards compat for non-interactive callers).
    +    """
    +    providers = authenticated_providers or ("openrouter", "nous")
    +    for provider in providers:
             result = resolve_alias(raw_input, provider)
             if result is not None:
                 return result
    ```

    ```diff
    @@ -494,7 +519,11 @@ def switch_model(
                 # --- Step b: Alias exists but not on current provider -> fallback ---
                 key = raw_input.strip().lower()
                 if key in MODEL_ALIASES:
    -                fallback_result = _resolve_alias_fallback(raw_input)
    +                authed = get_authenticated_provider_slugs(
    +                    current_provider=current_provider,
    +                    user_providers=user_providers,
    +                )
    +                fallback_result = _resolve_alias_fallback(raw_input, authed)
                     if fallback_result is not None:
                         target_provider, new_model, resolved_alias = fallback_result
                         logger.debug(
    ```
  - `tests/gateway/test_channel_directory.py`

    ```diff
    @@ -6,6 +6,7 @@
     from unittest.mock import patch
     
     from gateway.channel_directory import (
    +    build_channel_directory,
         resolve_channel_name,
         format_directory_for_display,
         load_directory,
    ```

    ```diff
    @@ -45,6 +46,27 @@ def test_corrupt_file(self, tmp_path):
             assert result["updated_at"] is None
     
     
    +class TestBuildChannelDirectoryWrites:
    +    def test_failed_write_preserves_previous_cache(self, tmp_path, monkeypatch):
    +        cache_file = _write_directory(tmp_path, {
    +            "telegram": [{"id": "123", "name": "Alice", "type": "dm"}]
    +        })
    +        previous = json.loads(cache_file.read_text())
    +
    +        def broken_dump(data, fp, *args, **kwargs):
    +            fp.write('{"updated_at":')
    +            fp.flush()
    +            raise OSError("disk full")
    +
    +        monkeypatch.setattr(json, "dump", broken_dump)
    +
    +        with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file):
    +            build_channel_directory({})
    +            result = load_directory()
    +
    +        assert result == previous
    +
    +
     class TestResolveChannelName:
         def _setup(self, tmp_path, platforms):
             cache_file = _write_directory(tmp_path, platforms)
    ```
  - `tests/gateway/test_session_model_reset.py`

    ```diff
    @@ -0,0 +1,126 @@
    +"""Tests that /new (and its /reset alias) clears the session-scoped model override."""
    +from datetime import datetime
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock, MagicMock
    +
    +import pytest
    +
    +from gateway.config import GatewayConfig, Platform, PlatformConfig
    +from gateway.platforms.base import MessageEvent
    +from gateway.session import SessionEntry, SessionSource, build_session_key
    +
    +
    +def _make_source() -> SessionSource:
    +    return SessionSource(
    +        platform=Platform.TELEGRAM,
    +        user_id="u1",
    +        chat_id="c1",
    +        user_name="tester",
    +        chat_type="dm",
    +    )
    +
    +
    +def _make_event(text: str) -> MessageEvent:
    +    return MessageEvent(text=text, source=_make_source(), message_id="m1")
    +
    +
    +def _make_runner():
    +    from gateway.run import GatewayRunner
    +
    +    runner = object.__new__(GatewayRunner)
    +    runner.config = GatewayConfig(
    +        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
    +    )
    +    adapter = MagicMock()
    +    adapter.send = AsyncMock()
    +    runner.adapters = {Platform.TELEGRAM: adapter}
    +    runner._voice_mode = {}
    +    runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
    +    runner._session_model_overrides = {}
    +    runner._pending_model_notes = {}
    +    runner._background_tasks = set()
    +
    +    session_key = build_session_key(_make_source())
    +    session_entry = SessionEntry(
    +        session_key=session_key,
    +        session_id="sess-1",
    +        created_at=datetime.now(),
    +        updated_at=datetime.now(),
    +        platform=Platform.TELEGRAM,
    +        chat_type="dm",
    +    )
    +    runner.session_store = MagicMock()
    +    runner.session_store.get_or_create_session.return_value = session_entry
    +    runner.session_store.reset_session.return_value = session_entry
    +    runner.session_store._entries = {session_key: session_entry}
    +    runner.session_store._generate_session_key.return_value = session_key
    +    runner._running_agents = {}
    +    runner._pending_messages = {}
    +    runner._pending_approvals = {}
    +    runner._session_db = None
    +    runner._agent_cache_lock = None  # disables _evict_cached_agent lock path
    +    runner._is_user_authorized = lambda _source: True
    +    runner._format_session_info = lambda: ""
    +
    +    return runner
    +
    +
    [redacted:email]
    +async def test_new_command_clears_session_model_override():
    +    """/new must remove the session-scoped model override for that session."""
    +    runner = _make_runner()
    +    session_key = build_session_key(_make_source())
    +
    +    # Simulate a prior /model switch stored as a session override
    +    runner._session_model_overrides[session_key] = {
    +        "model": "gpt-4o",
    +        "provider": "openai",
    +        "api_key": "[redacted:secret-assignment]",
    +        "base_url": "",
    +        "api_mode": "openai",
    +    }
    +
    +    await runner._handle_reset_command(_make_event("/new"))
    +
    +    assert session_key not in runner._session_model_overrides
    +
    +
    [redacted:email]
    +async def test_new_command_no_override_is_noop():
    +    """/new with no prior model override must not raise."""
    +    runner = _make_runner()
    +    session_key = build_session_key(_make_source())
    +
    +    assert session_key not in runner._session_model_overrides
    +
    +    await runner._handle_reset_command(_make_event("/new"))
    +
    +    assert session_key not in runner._session_model_overrides
    +
    +
    [redacted:email]
    +async def test_new_command_only_clears_own_session():
    +    """/new must only clear the override for the session that triggered it."""
    +    runner = _make_runner()
    +    session_key = build_session_key(_make_source())
    +    other_key = "other_session_key"
    +
    +    runner._session_model_overrides[session_key] = {
    +        "model": "gpt-4o",
    +        "provider": "openai",
    +        "api_key": "[redacted:secret-assignment]",
    +        "base_url": "",
    +        "api_mode": "openai",
    +    }
    +    runner._session_model_overrides[other_key] = {
    +        "model": "claude-sonnet-4-6",
    +        "provider": "anthropic",
    +        "api_key": "[redacted:secret-assignment]",
    +        "base_url": "",
    +        "api_mode": "anthropic",
    +    }
    +
    +    await runner._handle_reset_command(_make_event("/new"))
    +
    +    assert session_key not in runner._session_model_overrides
    +    assert other_key in runner._session_model_overrides
    ```
  - `tests/hermes_cli/test_gateway_service.py`

    ```diff
    @@ -205,6 +205,33 @@ def fake_run(cmd, check=False, **kwargs):
                 ["launchctl", "kickstart", target],
             ]
     
    +    def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkeypatch):
    +        """Exit code 113 (\"Could not find service\") should also trigger bootstrap recovery."""
    +        plist_path = tmp_path / "ai.hermes.gateway.plist"
    +        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
    +        label = gateway_cli.get_launchd_label()
    +
    +        calls = []
    +        domain = gateway_cli._launchd_domain()
    +        target = f"{domain}/{label}"
    +
    +        def fake_run(cmd, check=False, **kwargs):
    +            calls.append(cmd)
    +            if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1:
    +                raise gateway_cli.subprocess.CalledProcessError(113, cmd, stderr="Could not find service")
    +            return SimpleNamespace(returncode=0, stdout="", stderr="")
    +
    +        monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
    +        monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
    +
    +        gateway_cli.launchd_start()
    +
    +        assert calls == [
    +            ["launchctl", "kickstart", target],
    +            ["launchctl", "bootstrap", domain, str(plist_path)],
    +            ["launchctl", "kickstart", target],
    +        ]
    +
         def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
             plist_path = tmp_path / "ai.hermes.gateway.plist"
             plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -185,12 +185,28 @@ def _build_child_agent(
     
         # When no explicit toolsets given, inherit from parent's enabled toolsets
         # so disabled tools (e.g. web) don't leak to subagents.
    -    parent_toolsets = set(getattr(parent_agent, "enabled_toolsets", None) or DEFAULT_TOOLSETS)
    +    # Note: enabled_toolsets=None means "all tools enabled" (the default),
    +    # so we must derive effective toolsets from the parent's loaded tools.
    +    parent_enabled = getattr(parent_agent, "enabled_toolsets", None)
    +    if parent_enabled is not None:
    +        parent_toolsets = set(parent_enabled)
    +    elif parent_agent and hasattr(parent_agent, "valid_tool_names"):
    +        # enabled_toolsets is None (all tools) — derive from loaded tool names
    +        import model_tools
    +        parent_toolsets = {
    +            ts for name in parent_agent.valid_tool_names
    +            if (ts := model_tools.get_toolset_for_tool(name)) is not None
    +        }
    +    else:
    +        parent_toolsets = set(DEFAULT_TOOLSETS)
    +
         if toolsets:
             # Intersect with parent — subagent must not gain tools the parent lacks
             child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets])
    -    elif parent_agent and getattr(parent_agent, "enabled_toolsets", None):
    -        child_toolsets = _strip_blocked_tools(parent_agent.enabled_toolsets)
    +    elif parent_agent and parent_enabled is not None:
    +        child_toolsets = _strip_blocked_tools(parent_enabled)
    +    elif parent_toolsets:
    +        child_toolsets = _strip_blocked_tools(sorted(parent_toolsets))
         else:
             child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS)
     
    ```
- **#5591: fix: subagent toolset inheritance when parent enabled_toolsets is None**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#5630`

## Fix status

- Merged to main in PR #5630 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-06`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
