diff --git a/cli.py b/cli.py index 86875bcb60cd5..c43ec89407c1c 100644 --- a/cli.py +++ b/cli.py @@ -9835,6 +9835,8 @@ def chat(self, message, images: list = None) -> Optional[str]: request_overrides=turn_route.get("request_overrides"), ): return None + if getattr(self, "_suppress_single_query_post_turn_prefetch", False): + setattr(self.agent, "_suppress_post_turn_prefetch", True) # Route image attachments based on the active model's vision capability. # "native" → pass pixels as OpenAI-style content parts (adapters @@ -13566,6 +13568,7 @@ def _signal_handler_q(signum, frame): sys.exit(1) try: query, single_query_images = _collect_query_images(query, image) + cli._suppress_single_query_post_turn_prefetch = True # Kanban workers spawn with ``hermes chat -q "work kanban task "``; # the actual task description lives in the task body. Mirror the # gateway/CLI behaviour for inbound images by scanning the body for @@ -13673,6 +13676,11 @@ def _signal_handler_q(signum, frame): ): cli.agent.quiet_mode = True cli.agent.suppress_status_output = True + # Non-interactive single-query runs exit immediately after + # this turn, so there is no next turn to warm. Suppress + # post-turn memory prefetch to avoid spawning Honcho + # background threads that can race interpreter shutdown. + cli.agent._suppress_post_turn_prefetch = True # Suppress streaming display callbacks so stdout stays # machine-readable (no styled "Hermes" box, no tool-gen # status lines). The response is printed once below. diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 1041e8fd0b5e8..ad82bbde0b57c 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -391,9 +391,19 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, ) - # Store reference for atexit memory provider shutdown - global _active_agent_ref - _active_agent_ref = self.agent + # Store reference for atexit memory provider shutdown. + # This mixin lives in ``hermes_cli.cli_agent_setup_mixin``, but + # cleanup reads ``_active_agent_ref`` from the module that owns the + # concrete HermesCLI class: ``cli`` for installed/imported entry + # points, ``__main__`` for ``python cli.py``/Fire. Assign that + # owner module explicitly; a bare ``global`` here would create/update + # a shadow variable in this mixin module and leave cleanup blind to + # the live agent. + _cli_mod = sys.modules.get(self.__class__.__module__) + if _cli_mod is None or not hasattr(_cli_mod, "_active_agent_ref"): + _cli_mod = sys.modules.get("cli") + if _cli_mod is not None: + setattr(_cli_mod, "_active_agent_ref", self.agent) # Route agent status output through prompt_toolkit so ANSI escape # sequences aren't garbled by patch_stdout's StdoutProxy (#2262). self.agent._print_fn = _cprint diff --git a/run_agent.py b/run_agent.py index e81bf3b93e7ee..d3def35572fe0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2999,10 +2999,11 @@ def _sync_external_memory_for_turn( final_response, **sync_kwargs, ) - self._memory_manager.queue_prefetch_all( - original_user_message, - session_id=self.session_id or "", - ) + if not getattr(self, "_suppress_post_turn_prefetch", False): + self._memory_manager.queue_prefetch_all( + original_user_message, + session_id=self.session_id or "", + ) except Exception: pass diff --git a/tests/agent/test_memory_post_turn_prefetch.py b/tests/agent/test_memory_post_turn_prefetch.py new file mode 100644 index 0000000000000..b10e4431cee1a --- /dev/null +++ b/tests/agent/test_memory_post_turn_prefetch.py @@ -0,0 +1,59 @@ +"""Regression tests for suppressing useless post-turn memory prefetch. + +Single-query CLI sessions end immediately after the response. Queueing a +"next turn" prefetch in that path starts Honcho background threads that have no +consumer and can race interpreter shutdown. +""" + +from __future__ import annotations + +from run_agent import AIAgent + + +class _RecordingMemoryManager: + def __init__(self): + self.sync_calls = [] + self.prefetch_calls = [] + + def sync_all(self, user_content, assistant_content, **kwargs): + self.sync_calls.append((user_content, assistant_content, kwargs)) + + def queue_prefetch_all(self, query, **kwargs): + self.prefetch_calls.append((query, kwargs)) + + +def _agent_with_memory_manager(suppress_prefetch: bool = False): + agent = AIAgent.__new__(AIAgent) + setattr(agent, "_memory_manager", _RecordingMemoryManager()) + setattr(agent, "session_id", "sess-1") + setattr(agent, "_suppress_post_turn_prefetch", suppress_prefetch) + return agent + + +def test_external_memory_sync_can_suppress_next_turn_prefetch(): + agent = _agent_with_memory_manager(suppress_prefetch=True) + + agent._sync_external_memory_for_turn( + original_user_message="hello", + final_response="world", + interrupted=False, + messages=[{"role": "user", "content": "hello"}], + ) + + mm = getattr(agent, "_memory_manager") + assert len(mm.sync_calls) == 1 + assert mm.prefetch_calls == [] + + +def test_external_memory_sync_prefetches_by_default(): + agent = _agent_with_memory_manager(suppress_prefetch=False) + + agent._sync_external_memory_for_turn( + original_user_message="hello", + final_response="world", + interrupted=False, + ) + + mm = getattr(agent, "_memory_manager") + assert len(mm.sync_calls) == 1 + assert mm.prefetch_calls == [("hello", {"session_id": "sess-1"})] diff --git a/tests/cli/test_cli_active_agent_ref.py b/tests/cli/test_cli_active_agent_ref.py new file mode 100644 index 0000000000000..e8b466262ac57 --- /dev/null +++ b/tests/cli/test_cli_active_agent_ref.py @@ -0,0 +1,146 @@ +"""Regression tests for CLI active-agent cleanup wiring. + +The agent construction mixin lives in ``hermes_cli.cli_agent_setup_mixin`` but +process cleanup reads ``_active_agent_ref`` from the concrete CLI owner module. +A module-local assignment in the mixin leaves cleanup blind to the live agent, so +memory-provider background threads can survive until interpreter shutdown. +""" + +from __future__ import annotations + +import sys +import types + +import cli as cli_mod +from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin + + +class _FakeAgent: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.session_id = kwargs.get("session_id") + self._print_fn = None + + +class _DummyCLI(CLIAgentSetupMixin): + def __init__(self): + self.agent = None + self.api_key = "test-key" + self.base_url = "https://example.invalid" + self.provider = "openai" + self.api_mode = "chat" + self.acp_command = None + self.acp_args = [] + self.model = "test-model" + self.max_tokens = None + self.max_turns = 3 + self.enabled_toolsets = [] + self.disabled_toolsets = [] + self.verbose = False + self.tool_progress_mode = "off" + self.system_prompt = None + self.prefill_messages = [] + self.reasoning_config = None + self.service_tier = None + self._providers_only = None + self._providers_ignore = None + self._providers_order = None + self._provider_sort = None + self._provider_require_params = False + self._provider_data_collection = None + self._openrouter_min_coding_score = None + self.session_id = "test-session" + self._session_db = object() + self._fallback_model = None + self.checkpoints_enabled = False + self.checkpoint_max_snapshots = 0 + self.checkpoint_max_total_size_mb = 0 + self.checkpoint_max_file_size_mb = 0 + self.pass_session_id = False + self.ignore_rules = False + self.streaming_enabled = False + self._inline_diffs_enabled = False + self._active_agent_route_signature = None + self._pending_title = None + self._resumed = False + self.conversation_history = [] + self.requested_provider = None + self._explicit_api_key = None + self._explicit_base_url = None + self._credential_pool = None + + def _install_tool_callbacks(self): + pass + + def _ensure_tirith_security(self): + pass + + def _ensure_runtime_credentials(self): + return True + + def _clarify_callback(self, *args, **kwargs): + return "" + + def _current_reasoning_callback(self): + return None + + def _on_thinking(self, *args, **kwargs): + pass + + def _on_tool_progress(self, *args, **kwargs): + pass + + def _on_tool_start(self, *args, **kwargs): + pass + + def _on_tool_complete(self, *args, **kwargs): + pass + + def _stream_delta(self, *args, **kwargs): + pass + + def _on_tool_gen_start(self, *args, **kwargs): + pass + + def _on_notice(self, *args, **kwargs): + pass + + def _on_notice_clear(self, *args, **kwargs): + pass + + +def _patch_agent_startup(monkeypatch): + monkeypatch.setattr(cli_mod, "AIAgent", _FakeAgent) + monkeypatch.setattr(cli_mod, "_prepare_deferred_agent_startup", lambda: None) + monkeypatch.setattr("hermes_cli.mcp_startup.wait_for_mcp_discovery", lambda: None) + + +def test_init_agent_updates_cli_module_active_agent_ref(monkeypatch): + _patch_agent_startup(monkeypatch) + monkeypatch.setattr(cli_mod, "_active_agent_ref", None) + + dummy = _DummyCLI() + + assert dummy._init_agent() is True + assert dummy.agent is not None + assert cli_mod._active_agent_ref is dummy.agent + + +def test_init_agent_updates_script_owner_module_active_agent_ref(monkeypatch): + _patch_agent_startup(monkeypatch) + monkeypatch.setattr(cli_mod, "_active_agent_ref", None) + owner = types.ModuleType("fake_cli_script_owner") + setattr(owner, "_active_agent_ref", None) + monkeypatch.setitem(sys.modules, "fake_cli_script_owner", owner) + script_like_cli = type( + "ScriptLikeCLI", + (_DummyCLI,), + {"__module__": "fake_cli_script_owner"}, + ) + + dummy = script_like_cli() + + assert dummy._init_agent() is True + assert dummy.agent is not None + assert owner._active_agent_ref is dummy.agent + assert cli_mod._active_agent_ref is None