NousResearch/hermes-agent

Bug fixes: subagent toolset, config, session, writes

Cluster 5590 · agent. Data as of

Nothing open on GitHub. Fix merged in #5630 on 6 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #5630. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5630 fix: batch core bug fixes — model config, session reset, alias fallback, launchctl, delegation, atomic writes

    merged to main

    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

    Show the diff
    • cli.py
      @@ -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
      @@ -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__)
       
      @@ -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
      @@ -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 "",
      @@ -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")
      @@ -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
      @@ -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)
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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)
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #5591 fix: subagent toolset inheritance when parent enabled_toolsets is None · merged via another change → #5630
Closed issues · 1
  • Closed#5590Subagent toolset inheritance fails when parent enabled_toolsets is None
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#5630
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
6 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.