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
- 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
Open PRs0
Fixes on record
-
#5630 fix: batch core bug fixes — model config, session reset, alias fallback, launchctl, delegation, atomic writes
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.pyShow 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.00means 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 LabAt 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.