From 4aa94ae7cc13eba71f2b55afe7ba259025ad6805 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Wed, 11 Mar 2026 15:52:35 +0300 Subject: [PATCH 1/3] fix: detect non-interactive TTY in setup wizard to prevent hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes setup hung indefinitely on headless SSH sessions, Docker containers, and CI/CD environments because the interactive provider selection menu could not receive input. Two-layer fix: 1. sys.stdin.isatty() check — auto-detects non-interactive environments 2. --non-interactive flag support — already in CLI parser, now honored In both cases the wizard exits immediately with helpful guidance pointing users to 'hermes config set' commands. Closes #905 --- hermes_cli/setup.py | 22 ++++++++++ tests/hermes_cli/test_setup_noninteractive.py | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/hermes_cli/test_setup_noninteractive.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 4f1a1c247c3c..6eb2ce0a78fd 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2338,6 +2338,28 @@ def run_setup_wizard(args): config = load_config() hermes_home = get_hermes_home() + # Detect non-interactive environments (headless SSH, Docker, CI/CD) + non_interactive = getattr(args, 'non_interactive', False) + if not non_interactive and not sys.stdin.isatty(): + non_interactive = True + + if non_interactive: + print() + print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD)) + print() + print_info("Running in a non-interactive environment (no TTY detected).") + print_info("The interactive wizard cannot be used here.") + print() + print_info("Configure Hermes using environment variables or config commands:") + print_info(" hermes config set model.provider custom") + print_info(" hermes config set model.base_url http://localhost:8080/v1") + print_info(" hermes config set model.default your-model-name") + print() + print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.") + print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.") + print() + return + # Check if a specific section was requested section = getattr(args, "section", None) if section: diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py new file mode 100644 index 000000000000..724337bfd3f5 --- /dev/null +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -0,0 +1,43 @@ +"""Tests for non-interactive setup wizard behavior.""" +import pytest +from unittest.mock import patch, MagicMock + + +def _make_args(**kwargs): + args = MagicMock() + args.non_interactive = kwargs.get("non_interactive", False) + args.section = kwargs.get("section", None) + args.reset = kwargs.get("reset", False) + return args + + +class TestNonInteractiveSetup: + """Verify setup wizard exits cleanly in non-interactive environments.""" + + def test_non_interactive_flag_skips_wizard(self, capsys): + """--non-interactive flag should print help and return without hanging.""" + from hermes_cli.setup import run_setup_wizard + args = _make_args(non_interactive=True) + + with patch("hermes_cli.setup.ensure_hermes_home"), \ + patch("hermes_cli.setup.load_config", return_value={}), \ + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"): + run_setup_wizard(args) + + out = capsys.readouterr().out + assert "hermes config set" in out + + def test_no_tty_skips_wizard(self, capsys): + """When stdin has no TTY, wizard should exit with helpful message.""" + from hermes_cli.setup import run_setup_wizard + args = _make_args(non_interactive=False) + + with patch("hermes_cli.setup.ensure_hermes_home"), \ + patch("hermes_cli.setup.load_config", return_value={}), \ + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), \ + patch("sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + run_setup_wizard(args) + + out = capsys.readouterr().out + assert "hermes config set" in out From 5c479eedf1baa8d7229c867513b2805d58e7873c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:33:31 -0700 Subject: [PATCH 2/3] feat: improve context compaction handoff summaries (#1273) Adapt PR #916 onto current main by replacing the old context summary marker with a clearer handoff wrapper, updating the summarization prompt for resume-oriented summaries, and preserving the current call_llm-based compression path. --- agent/context_compressor.py | 41 +++++++++++++++++++------- tests/agent/test_context_compressor.py | 30 ++++++++++++++----- tests/test_413_compression.py | 3 +- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 5c0e0edf4573..aa05a8daa015 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -17,6 +17,16 @@ from agent.model_metadata import ( logger = logging.getLogger(__name__) +SUMMARY_PREFIX = ( + "[CONTEXT COMPACTION] Earlier turns in this conversation were compacted " + "to save context space. The summary below describes work that was " + "already completed, and the current session state may still reflect " + "that work (for example, files may already be changed). Use the summary " + "and the current state to continue from where things left off, and " + "avoid repeating work:" +) +LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" + class ContextCompressor: """Compresses conversation context when approaching the model's context limit. @@ -102,22 +112,22 @@ class ContextCompressor: parts.append(f"[{role.upper()}]: {content}") content_to_summarize = "\n\n".join(parts) - prompt = f"""Summarize these conversation turns concisely. This summary will replace these turns in the conversation history. + prompt = f"""Create a concise handoff summary for a later assistant that will continue this conversation after earlier turns are compacted. -Write from a neutral perspective describing: +Describe: 1. What actions were taken (tool calls, searches, file operations) 2. Key information or results obtained -3. Important decisions or findings -4. Relevant data, file names, or outputs +3. Important decisions, constraints, or user preferences +4. Relevant data, file names, outputs, or next steps needed to continue -Keep factual and informative. Target ~{self.summary_target_tokens} tokens. +Keep it factual, concise, and focused on helping the next assistant resume without repeating work. Target ~{self.summary_target_tokens} tokens. --- TURNS TO SUMMARIZE: {content_to_summarize} --- -Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" +Write only the summary body. Do not include any preamble or prefix; the system will add the handoff wrapper.""" # Use the centralized LLM router — handles provider resolution, # auth, and fallback internally. @@ -137,9 +147,7 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" if not isinstance(content, str): content = str(content) if content else "" summary = content.strip() - if not summary.startswith("[CONTEXT SUMMARY]:"): - summary = "[CONTEXT SUMMARY]: " + summary - return summary + return self._with_summary_prefix(summary) except RuntimeError: logging.warning("Context compression: no provider available for " "summary. Middle turns will be dropped without summary.") @@ -148,6 +156,16 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" logging.warning("Failed to generate context summary: %s", e) return None + @staticmethod + def _with_summary_prefix(summary: str) -> str: + """Normalize summary text to the current compaction handoff format.""" + text = (summary or "").strip() + for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX): + if text.startswith(prefix): + text = text[len(prefix):].lstrip() + break + return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX + # ------------------------------------------------------------------ # Tool-call / tool-result pair integrity helpers # ------------------------------------------------------------------ @@ -287,7 +305,10 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" for i in range(compress_start): msg = messages[i].copy() if i == 0 and msg.get("role") == "system" and self.compression_count == 0: - msg["content"] = (msg.get("content") or "") + "\n\n[Note: Some earlier conversation turns may be summarized to preserve context space.]" + msg["content"] = ( + (msg.get("content") or "") + + "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]" + ) compressed.append(msg) if summary: diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index dac64aaf6ddf..1f62490e3846 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -3,7 +3,7 @@ import pytest from unittest.mock import patch, MagicMock -from agent.context_compressor import ContextCompressor +from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX @pytest.fixture() @@ -138,7 +138,7 @@ class TestGenerateSummaryNoneContent: with patch("agent.context_compressor.call_llm", return_value=mock_response): summary = c._generate_summary(messages) assert isinstance(summary, str) - assert "CONTEXT SUMMARY" in summary + assert summary.startswith(SUMMARY_PREFIX) def test_none_content_in_system_message_compress(self): """System message with content=None should not crash during compress.""" @@ -172,7 +172,7 @@ class TestNonStringContent: with patch("agent.context_compressor.call_llm", return_value=mock_response): summary = c._generate_summary(messages) assert isinstance(summary, str) - assert "CONTEXT SUMMARY" in summary + assert summary.startswith(SUMMARY_PREFIX) def test_none_content_coerced_to_empty(self): mock_response = MagicMock() @@ -189,9 +189,19 @@ class TestNonStringContent: with patch("agent.context_compressor.call_llm", return_value=mock_response): summary = c._generate_summary(messages) - # None content → empty string → "[CONTEXT SUMMARY]: " prefix added + # None content → empty string → standardized compaction handoff prefix added assert summary is not None - assert "CONTEXT SUMMARY" in summary + assert summary == SUMMARY_PREFIX + + +class TestSummaryPrefixNormalization: + def test_legacy_prefix_is_replaced(self): + summary = ContextCompressor._with_summary_prefix("[CONTEXT SUMMARY]: did work") + assert summary == f"{SUMMARY_PREFIX}\ndid work" + + def test_existing_new_prefix_is_not_duplicated(self): + summary = ContextCompressor._with_summary_prefix(f"{SUMMARY_PREFIX}\ndid work") + assert summary == f"{SUMMARY_PREFIX}\ndid work" class TestCompressWithClient: @@ -211,7 +221,7 @@ class TestCompressWithClient: # Should have summary message in the middle contents = [m.get("content", "") for m in result] - assert any("CONTEXT SUMMARY" in c for c in contents) + assert any(c.startswith(SUMMARY_PREFIX) for c in contents) assert len(result) < len(msgs) def test_summarization_does_not_split_tool_call_pairs(self): @@ -283,7 +293,9 @@ class TestCompressWithClient: ] with patch("agent.context_compressor.call_llm", return_value=mock_response): result = c.compress(msgs) - summary_msg = [m for m in result if "CONTEXT SUMMARY" in (m.get("content") or "")] + summary_msg = [ + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ] assert len(summary_msg) == 1 assert summary_msg[0]["role"] == "user" @@ -311,7 +323,9 @@ class TestCompressWithClient: ] with patch("agent.context_compressor.call_llm", return_value=mock_response): result = c.compress(msgs) - summary_msg = [m for m in result if "CONTEXT SUMMARY" in (m.get("content") or "")] + summary_msg = [ + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ] assert len(summary_msg) == 1 assert summary_msg[0]["role"] == "assistant" diff --git a/tests/test_413_compression.py b/tests/test_413_compression.py index e35f67b4db50..da78cd3e4224 100644 --- a/tests/test_413_compression.py +++ b/tests/test_413_compression.py @@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch import pytest +from agent.context_compressor import SUMMARY_PREFIX from run_agent import AIAgent @@ -340,7 +341,7 @@ class TestPreflightCompression: # Simulate compression reducing messages mock_compress.return_value = ( [ - {"role": "user", "content": "[CONTEXT SUMMARY]: Previous conversation"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}, {"role": "user", "content": "hello"}, ], "new system prompt", From 9492f42aa7489b2ab89742ed4bd9259246f05b8e Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 02:37:29 -0700 Subject: [PATCH 3/3] fix: cover headless first-run setup flow --- hermes_cli/main.py | 9 ++ hermes_cli/setup.py | 49 +++++++--- tests/hermes_cli/test_setup_noninteractive.py | 95 ++++++++++++++----- .../test_setup_openclaw_migration.py | 3 + 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0d3e10d7bffa..4a31db808eef 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -478,6 +478,15 @@ def cmd_chat(args): print() print(" Run: hermes setup") print() + + from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance + + if not is_interactive_stdin(): + print_noninteractive_setup_guidance( + "No interactive TTY detected for the first-run setup prompt." + ) + sys.exit(1) + try: reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 6eb2ce0a78fd..5fd2950c995b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -176,6 +176,36 @@ def print_error(text: str): print(color(f"✗ {text}", Colors.RED)) +def is_interactive_stdin() -> bool: + """Return True when stdin looks like a usable interactive TTY.""" + stdin = getattr(sys, "stdin", None) + if stdin is None: + return False + try: + return bool(stdin.isatty()) + except Exception: + return False + + +def print_noninteractive_setup_guidance(reason: str | None = None) -> None: + """Print guidance for headless/non-interactive setup flows.""" + print() + print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD)) + print() + if reason: + print_info(reason) + print_info("The interactive wizard cannot be used here.") + print() + print_info("Configure Hermes using environment variables or config commands:") + print_info(" hermes config set model.provider custom") + print_info(" hermes config set model.base_url http://localhost:8080/v1") + print_info(" hermes config set model.default your-model-name") + print() + print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.") + print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.") + print() + + def prompt(question: str, default: str = None, password: bool = False) -> str: """Prompt for input with optional default.""" if default: @@ -2340,24 +2370,13 @@ def run_setup_wizard(args): # Detect non-interactive environments (headless SSH, Docker, CI/CD) non_interactive = getattr(args, 'non_interactive', False) - if not non_interactive and not sys.stdin.isatty(): + if not non_interactive and not is_interactive_stdin(): non_interactive = True if non_interactive: - print() - print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD)) - print() - print_info("Running in a non-interactive environment (no TTY detected).") - print_info("The interactive wizard cannot be used here.") - print() - print_info("Configure Hermes using environment variables or config commands:") - print_info(" hermes config set model.provider custom") - print_info(" hermes config set model.base_url http://localhost:8080/v1") - print_info(" hermes config set model.default your-model-name") - print() - print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.") - print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.") - print() + print_noninteractive_setup_guidance( + "Running in a non-interactive environment (no TTY detected)." + ) return # Check if a specific section was requested diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index 724337bfd3f5..4e76c013d26a 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -1,43 +1,94 @@ -"""Tests for non-interactive setup wizard behavior.""" +"""Tests for non-interactive setup and first-run headless behavior.""" + +from argparse import Namespace +from unittest.mock import patch + import pytest -from unittest.mock import patch, MagicMock -def _make_args(**kwargs): - args = MagicMock() - args.non_interactive = kwargs.get("non_interactive", False) - args.section = kwargs.get("section", None) - args.reset = kwargs.get("reset", False) - return args +def _make_setup_args(**overrides): + return Namespace( + non_interactive=overrides.get("non_interactive", False), + section=overrides.get("section", None), + reset=overrides.get("reset", False), + ) + + +def _make_chat_args(**overrides): + return Namespace( + continue_last=overrides.get("continue_last", None), + resume=overrides.get("resume", None), + model=overrides.get("model", None), + provider=overrides.get("provider", None), + toolsets=overrides.get("toolsets", None), + verbose=overrides.get("verbose", False), + query=overrides.get("query", None), + worktree=overrides.get("worktree", False), + yolo=overrides.get("yolo", False), + pass_session_id=overrides.get("pass_session_id", False), + quiet=overrides.get("quiet", False), + checkpoints=overrides.get("checkpoints", False), + ) class TestNonInteractiveSetup: - """Verify setup wizard exits cleanly in non-interactive environments.""" + """Verify setup paths exit cleanly in headless/non-interactive environments.""" def test_non_interactive_flag_skips_wizard(self, capsys): - """--non-interactive flag should print help and return without hanging.""" + """--non-interactive should print guidance and not enter the wizard.""" from hermes_cli.setup import run_setup_wizard - args = _make_args(non_interactive=True) - with patch("hermes_cli.setup.ensure_hermes_home"), \ - patch("hermes_cli.setup.load_config", return_value={}), \ - patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"): + args = _make_setup_args(non_interactive=True) + + with ( + patch("hermes_cli.setup.ensure_hermes_home"), + patch("hermes_cli.setup.load_config", return_value={}), + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), + patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): run_setup_wizard(args) out = capsys.readouterr().out - assert "hermes config set" in out + assert "hermes config set model.provider custom" in out def test_no_tty_skips_wizard(self, capsys): - """When stdin has no TTY, wizard should exit with helpful message.""" + """When stdin has no TTY, the setup wizard should print guidance and return.""" from hermes_cli.setup import run_setup_wizard - args = _make_args(non_interactive=False) - with patch("hermes_cli.setup.ensure_hermes_home"), \ - patch("hermes_cli.setup.load_config", return_value={}), \ - patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), \ - patch("sys.stdin") as mock_stdin: + args = _make_setup_args(non_interactive=False) + + with ( + patch("hermes_cli.setup.ensure_hermes_home"), + patch("hermes_cli.setup.load_config", return_value={}), + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), + patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), + patch("sys.stdin") as mock_stdin, + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): mock_stdin.isatty.return_value = False run_setup_wizard(args) out = capsys.readouterr().out - assert "hermes config set" in out + assert "hermes config set model.provider custom" in out + + def test_chat_first_run_headless_skips_setup_prompt(self, capsys): + """Bare `hermes` should not prompt for input when no provider exists and stdin is headless.""" + from hermes_cli.main import cmd_chat + + args = _make_chat_args() + + with ( + patch("hermes_cli.main._has_any_provider_configured", return_value=False), + patch("hermes_cli.main.cmd_setup") as mock_setup, + patch("sys.stdin") as mock_stdin, + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): + mock_stdin.isatty.return_value = False + with pytest.raises(SystemExit) as exc: + cmd_chat(args) + + assert exc.value.code == 1 + mock_setup.assert_not_called() + out = capsys.readouterr().out + assert "hermes config set model.provider custom" in out diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index 344079aa6a50..be5d61bab967 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -180,6 +180,7 @@ class TestSetupWizardOpenclawIntegration: patch.object(setup_mod, "load_config", return_value={}), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), # User presses Enter to start patch("builtins.input", return_value=""), @@ -214,6 +215,7 @@ class TestSetupWizardOpenclawIntegration: patch.object(setup_mod, "load_config", side_effect=tracking_load_config), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), patch("builtins.input", return_value=""), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), @@ -244,6 +246,7 @@ class TestSetupWizardOpenclawIntegration: ), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), patch("builtins.input", return_value=""), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),