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).
This commit is contained in:
teknium1
2026-09-19 01:18:30 -07:00
committed by Teknium
parent 7395931545
commit c30b750ad2
5 changed files with 50 additions and 9 deletions

View File

@@ -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.")

View File

@@ -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:

View File

@@ -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}"))

View File

@@ -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.")

View File

@@ -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