Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>"``;
# the actual task description lives in the task body. Mirror the
# gateway/CLI behaviour for inbound images by scanning the body for
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 13 additions & 3 deletions hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions tests/agent/test_memory_post_turn_prefetch.py
Original file line number Diff line number Diff line change
@@ -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"})]
146 changes: 146 additions & 0 deletions tests/cli/test_cli_active_agent_ref.py
Original file line number Diff line number Diff line change
@@ -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