diff --git a/agent/vault_backends/unlock.py b/agent/vault_backends/unlock.py index ffa621e0e7..492331e100 100644 --- a/agent/vault_backends/unlock.py +++ b/agent/vault_backends/unlock.py @@ -161,11 +161,7 @@ def is_unlocked(backend: str) -> bool: def can_prompt_here() -> bool: """False in contexts where no human can answer (cron, webhook, api_server, -q).""" - from tools.approval_context import ( - _is_cron_approval_context, - _is_single_query_approval_context, - _is_unattended_platform_approval_context, - ) - if _is_cron_approval_context() or _is_unattended_platform_approval_context() or _is_single_query_approval_context(): + from tools.approval_context import _no_user_can_answer + if _no_user_can_answer(): return False return get_unlock_prompt_callback() is not None diff --git a/tests/tools/test_write_approval.py b/tests/tools/test_write_approval.py index 30b30179a0..3b59de0c8d 100644 --- a/tests/tools/test_write_approval.py +++ b/tests/tools/test_write_approval.py @@ -318,50 +318,19 @@ def test_memory_inline_deny_blocks(hermes_home, approval_callback_cleanup): assert wa.pending_count("memory") == 0 # denied, not staged -@pytest.mark.parametrize("failure, staged", [("import", False), ("runtime", True)]) -def test_memory_probe_failure_preserves_approval_contract( - failure, staged, hermes_home, approval_callback_cleanup, monkeypatch +@pytest.mark.parametrize("context", ["single_query", "cron", "webhook"]) +def test_headless_memory_write_stages_without_inline_prompt( + hermes_home, approval_callback_cleanup, monkeypatch, context ): - import sys - - from tools import approval, write_approval as wa - from tools.memory_tool import memory_tool, MemoryStore - from tools.terminal_tool import set_approval_callback - - _set_approval("memory", True) - if failure == "import": - monkeypatch.setitem(sys.modules, "tools.approval", None) - else: - def broken_probe(): - raise RuntimeError("probe failed") - monkeypatch.setattr(approval, "_is_single_query_approval_context", broken_probe) - - calls = [] - def approve(*args, **kwargs): - calls.append(args) - return "once" - set_approval_callback(approve) - store = MemoryStore() - store.load_from_disk() - result = json.loads(memory_tool("add", "memory", "probe fact", store=store)) - - assert result["success"] is True - assert bool(result.get("staged")) is staged - assert bool(calls) is not staged - assert store.memory_entries == ([] if staged else ["probe fact"]) - assert wa.pending_count("memory") == int(staged) - - -def test_single_query_memory_stages_without_inline_prompt( - hermes_home, approval_callback_cleanup, monkeypatch -): - """Headless ``hermes chat -q`` runs have no human to answer a callback.""" + """`hermes chat -q`, cron and unattended platforms can register the CLI callback but have no human to answer + it: the write stages at once instead of waiting the approval timeout.""" from tools.memory_tool import memory_tool, MemoryStore from tools.terminal_tool import set_approval_callback from tools import write_approval as wa _set_approval("memory", True) - monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1") + monkeypatch.setenv(*{"single_query": ("HERMES_SINGLE_QUERY_SESSION", "1"), "cron": ("HERMES_CRON_SESSION", "1"), + "webhook": ("HERMES_SESSION_PLATFORM", "webhook")}[context]) calls = [] diff --git a/tools/approval_context.py b/tools/approval_context.py index f71381c33c..c206fa124e 100644 --- a/tools/approval_context.py +++ b/tools/approval_context.py @@ -155,6 +155,13 @@ def _is_single_query_approval_context() -> bool: return is_truthy_value(_session_env("HERMES_SINGLE_QUERY_SESSION")) +def _no_user_can_answer() -> bool: + """True in single-query (-q), cron and unattended-platform sessions. `hermes chat -q` still registers the + CLI panel callback, so a prompt that only checks for a callback would wait the full timeout for nobody.""" + return (_is_single_query_approval_context() or _is_cron_approval_context() + or _is_unattended_platform_approval_context()) + + def _is_gateway_approval_context() -> bool: """True inside a gateway/API session that can answer an approval. diff --git a/tools/approval_prompt.py b/tools/approval_prompt.py index 0d9484d43f..48b8dd7936 100644 --- a/tools/approval_prompt.py +++ b/tools/approval_prompt.py @@ -332,8 +332,7 @@ def request_elicitation_consent(message: str, description: str, *, # Nobody can answer a -q, cron or unattended-platform worker. A `hermes chat -q` turn still has the CLI's # panel callback registered, which would wait the full approval timeout before failing closed. - if (_ctx._is_single_query_approval_context() or _ctx._is_cron_approval_context() - or _ctx._is_unattended_platform_approval_context()): + if _ctx._no_user_can_answer(): logger.info("Elicitation consent (%s) declined: no user can answer in this session", surface) return "decline" diff --git a/tools/file_tools_write_guards.py b/tools/file_tools_write_guards.py index 43d2cd191f..1784cabde9 100644 --- a/tools/file_tools_write_guards.py +++ b/tools/file_tools_write_guards.py @@ -325,7 +325,8 @@ def _request_protected_instruction_approval(reasons: list[str], task_id: str = " # approvals timeout, then reported "timed out" for a prompt no human saw. Detect that context the same # way ``_run_approval_gate`` does (#86878) and fail closed immediately with the honest reason. Single-query # runs are NEVER auto-approved here, whatever ``approvals.single_query_mode`` says. - if _approval._is_single_query_approval_context(): + from tools.approval_context import _no_user_can_answer + if _no_user_can_answer(): return blocked.format(why=_NO_HUMAN) # Same observer payload as the gateway branch (#131876), fired like the # dangerous-command CLI prompt in tools/approval.py. diff --git a/tools/write_approval.py b/tools/write_approval.py index ae40a584d7..8c4def4491 100644 --- a/tools/write_approval.py +++ b/tools/write_approval.py @@ -216,50 +216,6 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: st return GateDecision(blocked=True, message="Memory write denied by user. The change was not saved.") -def _interactive_approval_available() -> bool: - """True when a foreground memory write can be approved inline. - - Inline prompting requires both a live interactive surface and a per-thread - approval callback registered by the interactive CLI - (``tools.terminal_tool.set_approval_callback``). A callback alone is not - sufficient: headless ``hermes chat -q`` runs also register one, but have no - human available to answer it. Kanban workers inherit this behavior because - the dispatcher launches them through ``hermes chat -q``. - - Every other surface stages instead: - - * **Single-query runs (including Kanban workers)** — headless even though a - CLI callback is registered; inline prompting would wait until timeout. - * **Gateway/API sessions** — the dangerous-command ``/approve`` round-trip - lives in the pending-approval queue (``submit_pending`` + - ``_await_gateway_decision``), which ``prompt_dangerous_approval`` never - reaches; trying to prompt from a gateway session would hit the - ``input()`` fallback and silently deny. Staging gives the user a real - review affordance (``/memory pending``) instead. - * Scripts, cron, and background threads — no user present. - """ - try: - from tools.approval import _is_single_query_approval_context - except ImportError: - # Keep the pre-existing callback path available if the optional context - # probe cannot be imported during early startup. - pass - else: - try: - if _is_single_query_approval_context(): - return False - except Exception: - # An unreadable headless-context probe must fail safe to staging. - return False - - try: - from tools.terminal_tool import _get_approval_callback - - return _get_approval_callback() is not None - except Exception: - return False - - def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]: """Prompt inline for a memory write: True approved, False denied, None → stage. Uses the per-thread CLI approval callback (``tools.terminal_tool.set_approval_callback``) directly, not @@ -267,9 +223,11 @@ def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]: prompt_toolkit; silent deny in gateway sessions) and turns callback errors into a deny, whereas here a missing channel or failed prompt must stage instead. - See #15216. + ``hermes chat -q`` (and so every kanban worker), cron and unattended platforms can register a callback too, + but nobody answers it: stage at once instead of waiting the approval timeout. See #15216. """ - if not _interactive_approval_available(): + from tools.approval_context import _no_user_can_answer + if _no_user_can_answer(): return None try: from tools.terminal_tool import _get_approval_callback