diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 600b4d4a9958..fad716cc6b88 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11217,6 +11217,7 @@ def _try_termux_fast_cli_launch() -> bool: model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + ignore_rules=getattr(args, "ignore_rules", False), ) ) @@ -14262,6 +14263,7 @@ def cmd_acp(args): model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + ignore_rules=getattr(args, "ignore_rules", False), ) ) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index b79644f67061..9a2a8adf8679 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -5,7 +5,10 @@ Toolsets = explicit --toolsets when provided, otherwise whatever the user has configured for "cli" in `hermes tools`. -Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn. +Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn, +unless --ignore-rules (or HERMES_IGNORE_RULES=1) is set — then +AGENTS.md / SOUL.md / .cursorrules auto-injection and persistent memory are +skipped, mirroring the `hermes chat --ignore-rules` semantic. Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call). Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual). @@ -127,6 +130,7 @@ def run_oneshot( model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, + ignore_rules: bool = False, ) -> int: """Execute a single prompt and print only the final content block. @@ -137,6 +141,9 @@ def run_oneshot( provider: Optional provider override. Falls back to config.yaml's model.provider, then "auto". toolsets: Optional comma-separated string or iterable of toolsets. + ignore_rules: When True (or HERMES_IGNORE_RULES=1 in the environment), + skip auto-injection of SOUL.md, AGENTS.md, .cursorrules, and + persistent memory — mirroring `hermes chat --ignore-rules`. Returns the exit code. Caller should sys.exit() with the return. """ @@ -176,6 +183,8 @@ def run_oneshot( real_stdout = sys.stdout devnull = open(os.devnull, "w", encoding="utf-8") + effective_ignore_rules = bool(ignore_rules) or os.environ.get("HERMES_IGNORE_RULES") == "1" + try: with redirect_stdout(devnull), redirect_stderr(devnull): response = _run_agent( @@ -184,6 +193,7 @@ def run_oneshot( provider=provider, toolsets=explicit_toolsets, use_config_toolsets=use_config_toolsets, + ignore_rules=effective_ignore_rules, ) finally: try: @@ -221,6 +231,7 @@ def _run_agent( provider: Optional[str] = None, toolsets: object = None, use_config_toolsets: bool = True, + ignore_rules: bool = False, ) -> str: """Build an AIAgent exactly like a normal CLI chat turn would, then run a single conversation. Returns the final response string.""" @@ -317,6 +328,8 @@ def _run_agent( session_db=session_db, credential_pool=runtime.get("credential_pool"), fallback_model=_fb or None, + skip_context_files=ignore_rules, + skip_memory=ignore_rules, # Interactive callbacks are intentionally NOT wired beyond this # one. In oneshot mode there's no user sitting at a terminal: # - clarify → returns a synthetic "pick a default" instruction diff --git a/tests/hermes_cli/test_oneshot_ignore_rules.py b/tests/hermes_cli/test_oneshot_ignore_rules.py new file mode 100644 index 000000000000..2c6101821e50 --- /dev/null +++ b/tests/hermes_cli/test_oneshot_ignore_rules.py @@ -0,0 +1,248 @@ +"""Regression tests for ``hermes -z --ignore-rules``. + +The ``--ignore-rules`` flag is documented as: "Skip auto-injection of +AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills". It works +on the chat path (covered by ``test_ignore_user_config_flags.py``) but was +a silent no-op on the oneshot path before this fix because +``hermes_cli/oneshot.run_oneshot()`` did not read the flag or the +``HERMES_IGNORE_RULES`` env var, and did not pass ``skip_context_files`` / +``skip_memory`` to ``AIAgent``. + +See #26633 for the full root-cause writeup. These tests cover the wiring +end-to-end: parser → main.run_oneshot → _run_agent → AIAgent kwargs. +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False) + yield + os.environ.pop("HERMES_IGNORE_RULES", None) + + +class TestRunAgentForwardsIgnoreRules: + """``_run_agent`` must translate ``ignore_rules`` into the two AIAgent + kwargs that actually suppress AGENTS.md/SOUL.md/.cursorrules + memory. + """ + + def _build_agent_mock(self): + agent_instance = MagicMock() + agent_instance.chat.return_value = "ok" + return agent_instance + + def _patch_dependencies(self, monkeypatch, agent_instance): + from hermes_cli import oneshot + + fake_agent_cls = MagicMock(return_value=agent_instance) + + # Stub the heavy resolver dependencies so we can drive _run_agent + # without needing config files / network / credentials. + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"model": {"default": "x"}} + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_: { + "api_key": "k", + "base_url": "https://example", + "provider": "openrouter", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda cfg, name: set() + ) + monkeypatch.setattr("run_agent.AIAgent", fake_agent_cls) + # Avoid touching ~/.hermes/session.sqlite during tests. + monkeypatch.setattr( + oneshot, "_create_session_db_for_oneshot", lambda: None + ) + return fake_agent_cls + + def test_ignore_rules_true_sets_both_skip_kwargs(self, monkeypatch): + from hermes_cli.oneshot import _run_agent + + agent_instance = self._build_agent_mock() + fake_agent_cls = self._patch_dependencies(monkeypatch, agent_instance) + + _run_agent("hi", model="x", provider="openrouter", ignore_rules=True) + + kwargs = fake_agent_cls.call_args.kwargs + assert kwargs.get("skip_context_files") is True + assert kwargs.get("skip_memory") is True + + def test_ignore_rules_false_leaves_both_skip_kwargs_false(self, monkeypatch): + from hermes_cli.oneshot import _run_agent + + agent_instance = self._build_agent_mock() + fake_agent_cls = self._patch_dependencies(monkeypatch, agent_instance) + + _run_agent("hi", model="x", provider="openrouter", ignore_rules=False) + + kwargs = fake_agent_cls.call_args.kwargs + assert kwargs.get("skip_context_files") is False + assert kwargs.get("skip_memory") is False + + def test_default_is_false(self, monkeypatch): + """Omitting the kwarg must match the pre-fix behavior (rules ON).""" + from hermes_cli.oneshot import _run_agent + + agent_instance = self._build_agent_mock() + fake_agent_cls = self._patch_dependencies(monkeypatch, agent_instance) + + _run_agent("hi", model="x", provider="openrouter") + + kwargs = fake_agent_cls.call_args.kwargs + assert kwargs.get("skip_context_files") is False + assert kwargs.get("skip_memory") is False + + +class TestRunOneshotForwardsToRunAgent: + """``run_oneshot`` must propagate its ``ignore_rules`` argument and the + ``HERMES_IGNORE_RULES`` env-var fallback through to ``_run_agent``. + """ + + def _captured_run_agent(self): + captured: dict = {} + + def fake(*args, **kwargs): + captured.update(kwargs) + captured.setdefault("_args", args) + return "ok" + + return captured, fake + + def test_explicit_param_propagates(self, monkeypatch): + from hermes_cli import oneshot + + captured, fake = self._captured_run_agent() + monkeypatch.setattr(oneshot, "_run_agent", fake) + + rc = oneshot.run_oneshot("hello", ignore_rules=True) + assert rc == 0 + assert captured.get("ignore_rules") is True + + def test_env_var_propagates_when_param_false(self, monkeypatch): + from hermes_cli import oneshot + + captured, fake = self._captured_run_agent() + monkeypatch.setattr(oneshot, "_run_agent", fake) + monkeypatch.setenv("HERMES_IGNORE_RULES", "1") + + rc = oneshot.run_oneshot("hello") + assert rc == 0 + assert captured.get("ignore_rules") is True + + def test_neither_param_nor_env_means_false(self, monkeypatch): + from hermes_cli import oneshot + + captured, fake = self._captured_run_agent() + monkeypatch.setattr(oneshot, "_run_agent", fake) + monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False) + + rc = oneshot.run_oneshot("hello") + assert rc == 0 + assert captured.get("ignore_rules") is False + + def test_env_var_other_value_is_falsy(self, monkeypatch): + """Only the literal "1" activates the gate (same as the chat path).""" + from hermes_cli import oneshot + + captured, fake = self._captured_run_agent() + monkeypatch.setattr(oneshot, "_run_agent", fake) + monkeypatch.setenv("HERMES_IGNORE_RULES", "true") + + rc = oneshot.run_oneshot("hello") + assert rc == 0 + assert captured.get("ignore_rules") is False + + +class TestMainDispatchForwardsIgnoreRules: + """The top-level ``--oneshot`` / ``-z`` dispatch in ``hermes_cli/main.py`` + must pass ``args.ignore_rules`` to ``run_oneshot``. Without this, the + explicit param wiring above never gets exercised on the real CLI path. + """ + + def test_main_oneshot_path_forwards_ignore_rules(self): + import inspect + import hermes_cli.main as hm + + src = inspect.getsource(hm) + assert "run_oneshot(" in src, "main.py must still dispatch to run_oneshot" + # main.py has more than one oneshot dispatch site (fast-path vs full + # parser path). Every call site must forward args.ignore_rules; if + # even one site drops it, --ignore-rules is a silent no-op on whichever + # path the bare `hermes -z` invocation takes. + sites = [] + cursor = 0 + while True: + idx = src.find("run_oneshot(", cursor) + if idx == -1: + break + sites.append(src[idx : idx + 400]) + cursor = idx + 1 + assert sites, "expected at least one run_oneshot(...) dispatch in main.py" + missing = [i for i, w in enumerate(sites) if "ignore_rules=" not in w] + assert not missing, ( + f"main.py oneshot dispatch site(s) {missing} (of {len(sites)} total) " + "must forward args.ignore_rules to run_oneshot; without this " + "--ignore-rules is a silent no-op on -z (#26633)." + ) + + +class TestRegressionGuard: + """Old behavior: before this fix, ``run_oneshot`` did not accept + ``ignore_rules`` and ``_run_agent`` never set ``skip_context_files`` / + ``skip_memory``. If anyone reverts the oneshot wiring, this guard fails. + """ + + def test_run_oneshot_signature_has_ignore_rules(self): + import inspect + from hermes_cli.oneshot import run_oneshot + + sig = inspect.signature(run_oneshot) + assert "ignore_rules" in sig.parameters, ( + "run_oneshot must accept ignore_rules; without it the -z path " + "cannot honor --ignore-rules (#26633)." + ) + + def test_run_agent_passes_both_skip_kwargs(self, monkeypatch): + """If a future refactor drops either kwarg, both flag effects break.""" + from hermes_cli.oneshot import _run_agent + + agent_instance = MagicMock() + agent_instance.chat.return_value = "ok" + fake_cls = MagicMock(return_value=agent_instance) + + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"model": {"default": "x"}} + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_: {"api_key": "k", "base_url": "u", "provider": "p", "api_mode": "chat_completions", "credential_pool": None}, + ) + monkeypatch.setattr( + "hermes_cli.tools_config._get_platform_tools", lambda cfg, name: set() + ) + monkeypatch.setattr("run_agent.AIAgent", fake_cls) + + from hermes_cli import oneshot + monkeypatch.setattr(oneshot, "_create_session_db_for_oneshot", lambda: None) + + _run_agent("hi", model="x", provider="p", ignore_rules=True) + + kwargs = fake_cls.call_args.kwargs + # Both kwargs must be wired — one without the other still leaks + # either rules (AGENTS.md / SOUL.md / .cursorrules) or memory. + assert "skip_context_files" in kwargs + assert "skip_memory" in kwargs + assert kwargs["skip_context_files"] is True + assert kwargs["skip_memory"] is True diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index bcf552a8f104..71015c31ebc5 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -379,6 +379,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod) "model": "gpt-test", "provider": "openai", "toolsets": None, + "ignore_rules": False, } @@ -617,6 +618,7 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): "model": None, "provider": None, "toolsets": "web,terminal", + "ignore_rules": False, }