From c30b750ad25b8a17deebc71c3f8af1177da9d6ce Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:18:30 -0700 Subject: [PATCH] fix(cli): release the old agent's clients before dropping it for a rebuild Interactive CLI sites that set self.agent = None (/personality, /reasoning, /fast, model/route/credential change, first-run setup, MoA one-shot) now go through _retire_agent(), which calls agent.release_clients() first: on the codex_app_server route the app-server child belongs to the instance and was orphaned for the CLI process lifetime (#72548). --- hermes_cli/cli_agent_setup_mixin.py | 14 ++++++++++-- hermes_cli/cli_chat_turn_mixin.py | 6 +++-- hermes_cli/cli_commands_mixin.py | 9 ++++---- hermes_cli/cli_model_switch_mixin.py | 3 ++- tests/hermes_cli/test_cli_retire_agent.py | 27 +++++++++++++++++++++++ 5 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 tests/hermes_cli/test_cli_retire_agent.py diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index c7d5f69683..d866a48b23 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -212,6 +212,16 @@ def _resume_panel_colors() -> tuple: return tuple(default for _, default in _RESUME_SKIN_COLORS) +def _retire_agent(cli) -> None: + """Drop ``cli.agent`` so the next turn rebuilds it, releasing its LLM clients first: the Codex + app-server child (and MCP descendants) belongs to the instance, so ``self.agent = None`` alone + orphans it for the CLI process lifetime (#72548). Session tool state is kept (soft release).""" + agent = cli.agent + if agent is not None and hasattr(agent, "release_clients"): + agent.release_clients() + cli.agent = None + + class CLIAgentSetupMixin: """Agent construction + session-resume display methods for ``HermesCLI``.""" @@ -324,7 +334,7 @@ class CLIAgentSetupMixin: # AIAgent/OpenAI client holds auth at init, so rebuild on key/routing/model change. if (credentials_changed or routing_changed or model_changed) and self.agent is not None: - self.agent = None + _retire_agent(self) self._active_agent_route_signature = None return True @@ -492,7 +502,7 @@ class CLIAgentSetupMixin: except Exception as exc: logger.debug("first-run config re-sync failed: %s", exc) # Force credential re-resolution + agent rebuild on next use. - self.agent = None + _retire_agent(self) self._active_agent_route_signature = None if self._runtime_credentials_ready(): _cprint(" ✓ Provider configured — you're ready to chat.") diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index 4aaa1a27dd..31188cce46 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -18,6 +18,8 @@ from rich import box as rich_box from rich.panel import Panel from typing import Optional +from hermes_cli.cli_agent_setup_mixin import _retire_agent + class CLIChatTurnMixin: """chat() and its per-turn phase helpers.""" @@ -51,7 +53,7 @@ class CLIChatTurnMixin: turn_route = self._resolve_turn_agent_config(message) if turn_route["signature"] != self._active_agent_route_signature: - self.agent = None + _retire_agent(self) if self.agent is None: _cprint(f"{_DIM}Initializing agent...{_RST}") if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"], @@ -341,7 +343,7 @@ class CLIChatTurnMixin: for _key, _value in _restore.items(): if _value is not None: setattr(self, _key, _value) - self.agent = None + _retire_agent(self) self._pending_moa_restore_model = None self._pending_moa_disable_after_turn = False except Exception as exc: diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4f4806243c..d8c7bed492 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -31,6 +31,7 @@ from rich.panel import Panel from hermes_constants import display_hermes_home, is_termux as _is_termux_environment from hermes_state_ids import new_session_id as mint_session_id from agent.turn_context import extract_api_content_sidecar +from hermes_cli.cli_agent_setup_mixin import _retire_agent from hermes_cli.browser_connect import ( DEFAULT_BROWSER_CDP_URL, discover_local_cdp_url, find_free_debug_port, is_browser_debug_ready, launch_chrome_debug, local_port_in_use, manual_chrome_debug_command) @@ -1555,12 +1556,12 @@ class CLICommandsMixin: cfg_get(read_raw_config(), "agent", "system_prompt", default="")) except Exception: self.system_prompt = "" - self.agent = None # Force re-init + _retire_agent(self) # Force re-init _pr(f"{face} Personality cleared {scope}", " No personality overlay — using base agent behavior.") else: self.system_prompt = personality_prompt - self.agent = None # Force re-init + _retire_agent(self) # Force re-init _pr(f"{face} Personality set to '{name}' {scope}", f" \"{_ellipsize(personality_prompt, 60)}\"") @@ -2595,7 +2596,7 @@ class CLICommandsMixin: _dim_line('Display: show, hide'), _dim_line('Scope: session-scoped by default, --global to persist')) self.reasoning_config = parsed - self.agent = None # Force agent re-init with new reasoning config + _retire_agent(self) # Force agent re-init with new reasoning config saved = explicit_global and _save("agent.reasoning_effort", arg) if saved: if not isinstance(CLI_CONFIG.get("agent"), dict): @@ -2652,7 +2653,7 @@ class CLICommandsMixin: if arg not in _FAST_TIERS: return _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage) self.service_tier, saved_value = _FAST_TIERS[arg] - self.agent = None # Force agent re-init with new service-tier config + _retire_agent(self) # Force agent re-init with new service-tier config saved = explicit_global and _save("agent.service_tier", saved_value) outcome = _scope_outcome(explicit_global, saved) _cp(_accent_line(f"✓ {feature_name} set to {saved_value.upper()} {outcome}")) diff --git a/hermes_cli/cli_model_switch_mixin.py b/hermes_cli/cli_model_switch_mixin.py index 1f605f1d6a..9d6a12a62f 100644 --- a/hermes_cli/cli_model_switch_mixin.py +++ b/hermes_cli/cli_model_switch_mixin.py @@ -16,6 +16,7 @@ import threading from rich.markup import escape as _escape from utils import base_url_host_matches +from hermes_cli.cli_agent_setup_mixin import _retire_agent # CLI-level fields describing the active model route; snapshotted before a switch / one-turn # override and restored wholesale on rollback. ``reasoning_config`` rides along because it is @@ -910,7 +911,7 @@ class CLIModelSwitchMixin: self.api_key = "moa-virtual-provider" self.base_url = "moa://local" self.api_mode = "chat_completions" - self.agent = None + _retire_agent(self) self._pending_moa_disable_after_turn = True self._pending_agent_seed = payload _cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.") diff --git a/tests/hermes_cli/test_cli_retire_agent.py b/tests/hermes_cli/test_cli_retire_agent.py new file mode 100644 index 0000000000..1c69e555f3 --- /dev/null +++ b/tests/hermes_cli/test_cli_retire_agent.py @@ -0,0 +1,27 @@ +"""The interactive CLI must release the old AIAgent's LLM clients when it drops the instance +for a rebuild (/personality, /reasoning, /fast, model/route/credential change, MoA one-shot): +on the codex_app_server route the app-server child belongs to that instance and ``self.agent = +None`` alone orphans it for the CLI process lifetime (#72548).""" + +from types import SimpleNamespace +from unittest.mock import patch + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + + +class _FakeAgent: + def __init__(self): + self.release_calls = 0 + + def release_clients(self): + self.release_calls += 1 + + +def test_reasoning_command_releases_old_agent_clients_before_rebuild(): + agent = _FakeAgent() + stub = SimpleNamespace(reasoning_config={"enabled": True, "effort": "medium"}, show_reasoning=False, agent=agent) + with patch("cli.save_config_value"), patch("cli._cprint"): + CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high") + assert stub.reasoning_config == {"enabled": True, "effort": "high"} + assert stub.agent is None + assert agent.release_calls == 1