{"repo":"NousResearch/hermes-agent","public_id":"15194","display_label":"Vision auto-detect and CLI input hang fixes","summary":"This cluster addresses indefinite hang and deadlock issues in the vision auto-detection path and CLI input handling, including fixes for provider ordering, timeout bounds, and prompt_toolkit blocking vectors.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16574},"resolution":{"state":"partially_resolved","by":"pr-16574"},"freshness":{"state":"verified","last_verified_at":"2026-04-27T13:42:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":15216,"pr_number":16574,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":15194,"title":"[Bug]: auxiliary_client (Vision auto-detect / title generation) hangs indefinitely with no timeout when provider stalls; CLI input blocks","url":null,"state":"closed"},{"kind":"issue","number":21037,"title":"Hermes hangs after \"Vision auto-detect\" with no LLM call attempt","url":null,"state":"closed"},{"kind":"pr","number":16574,"title":"fix(approval): close prompt_toolkit deadlock vectors (#15216)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_background_review.py","tests/tools/test_approval.py","tools/approval.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -74,6 +74,12 @@\n     check_toolset_requirements,\n )\n from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env\n+from tools.terminal_tool import (\n+    set_approval_callback as _set_approval_callback,\n+    set_sudo_password_callback as _set_sudo_password_callback,\n+    _get_approval_callback,\n+    _get_sudo_password_callback,\n+)\n from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget\n from tools.interrupt import set_interrupt as _set_interrupt\n from tools.browser_tool import cleanup_browser","@@ -3241,6 +3247,21 @@ def _spawn_background_review(\n \n         def _run_review():\n             import contextlib\n+            # Install a non-interactive approval callback on this worker\n+            # thread so any dangerous-command guard the review agent trips\n+            # resolves to \"deny\" instead of falling back to input() -- which\n+            # deadlocks against the parent's prompt_toolkit TUI (#15216).\n+            # Same pattern as _subagent_auto_deny in tools/delegate_tool.py.\n+            def _bg_review_auto_deny(command, description, **kwargs):\n+                logger.warning(\n+                    \"Background review auto-denied dangerous command: %s (%s)\",\n+                    command, description,\n+                )\n+                return \"deny\"\n+            try:\n+                _set_approval_callback(_bg_review_auto_deny)\n+            except Exception:\n+                pass\n             review_agent = None\n             try:\n                 with open(os.devnull, \"w\") as _devnull, \\","@@ -3322,6 +3343,12 @@ def _run_review():\n                         review_agent.close()\n                     except Exception:\n                         pass\n+                # Clear the approval callback on this bg-review thread so a\n+                # recycled thread-id doesn't inherit a stale reference.\n+                try:\n+                    _set_approval_callback(None)\n+                except Exception:\n+                    pass\n \n         t = threading.Thread(target=_run_review, daemon=True, name=\"bg-review\")\n         t.start()","@@ -8469,6 +8496,14 @@ def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effe\n         self._current_tool = tool_names_str\n         self._touch_activity(f\"executing {num_tools} tools concurrently: {tool_names_str}\")\n \n+        # Capture CLI callbacks from the agent thread so worker threads can\n+        # register them locally.  Without this, _get_approval_callback() in\n+        # terminal_tool returns None in ThreadPoolExecutor workers, causing\n+        # the dangerous-command prompt to fall back to input() — which\n+        # deadlocks against prompt_toolkit's raw terminal mode (#13617).\n+        _parent_approval_cb = _get_approval_callback()\n+        _parent_sudo_cb = _get_sudo_password_callback()\n+\n         def _run_tool(index, tool_call, function_name, function_args):\n             \"\"\"Worker function executed in a thread.\"\"\"\n             # Register this worker tid so the agent can fan out an interrupt","@@ -8495,6 +8530,18 @@ def _run_tool(index, tool_call, function_name, function_args):\n                 set_activity_callback(self._touch_activity)\n             except Exception:\n                 pass\n+            # Propagate approval/sudo callbacks to this worker thread.\n+            # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).\n+            if _parent_approval_cb is not None:\n+                try:\n+                    _set_approval_callback(_parent_approval_cb)\n+                except Exception:\n+                    pass\n+            if _parent_sudo_cb is not None:\n+                try:\n+                    _set_sudo_password_callback(_parent_sudo_cb)\n+                except Exception:\n+                    pass\n             start = time.time()\n             try:\n                 result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id, messages=messages)","@@ -8517,6 +8564,13 @@ def _run_tool(index, tool_call, function_name, function_args):\n                 _set_interrupt(False, _worker_tid)\n             except Exception:\n                 pass\n+            # Clear thread-local callbacks so a recycled worker thread\n+            # doesn't hold stale references to a disposed CLI instance.\n+            try:\n+                _set_approval_callback(None)\n+                _set_sudo_password_callback(None)\n+            except Exception:\n+                pass\n \n         # Start spinner for CLI mode (skip when TUI handles tool progress)\n         spinner = None"]},{"file":"scripts/release.py","hunks":["@@ -553,6 +553,7 @@\n     \"[redacted:email]\": \"chen1749144759\",\n     \"[redacted:email]\": \"MorAlekss\",\n     \"[redacted:email]\": \"ash\",\n+    \"[redacted:email]\": \"andrewhosf\",\n }\n \n "]},{"file":"tests/run_agent/test_background_review.py","hunks":["@@ -71,3 +71,59 @@ def close(self):\n         \"shutdown_memory_provider\",\n         \"close\",\n     ]\n+\n+\n+def test_background_review_installs_auto_deny_approval_callback(monkeypatch):\n+    \"\"\"Regression guard for #15216.\n+\n+    The background review thread must install a non-interactive approval\n+    callback. If it doesn't, any dangerous-command guard the review agent\n+    trips falls back to input() on a daemon thread, which deadlocks against\n+    the parent's prompt_toolkit TUI.\n+    \"\"\"\n+    import tools.terminal_tool as tt\n+\n+    observed: dict = {\"during_run\": \"<unread>\", \"after_finally\": \"<unread>\"}\n+\n+    class FakeReviewAgent:\n+        def __init__(self, **kwargs):\n+            self._session_messages = []\n+\n+        def run_conversation(self, **kwargs):\n+            # Capture what the callback looks like mid-run. It must be\n+            # a callable (the auto-deny) -- not None.\n+            observed[\"during_run\"] = tt._get_approval_callback()\n+\n+        def shutdown_memory_provider(self):\n+            pass\n+\n+        def close(self):\n+            pass\n+\n+    monkeypatch.setattr(run_agent_module, \"AIAgent\", FakeReviewAgent)\n+    monkeypatch.setattr(run_agent_module.threading, \"Thread\", ImmediateThread)\n+\n+    # Start from a clean slot.\n+    tt.set_approval_callback(None)\n+    agent = _bare_agent()\n+\n+    AIAgent._spawn_background_review(\n+        agent,\n+        messages_snapshot=[{\"role\": \"user\", \"content\": \"hello\"}],\n+        review_memory=True,\n+    )\n+\n+    observed[\"after_finally\"] = tt._get_approval_callback()\n+\n+    assert callable(observed[\"during_run\"]), (\n+        \"Background review did not install an approval callback on its \"\n+        \"worker thread; dangerous-command prompts will deadlock against \"\n+        \"the parent TUI (#15216).\"\n+    )\n+    # The installed callback must deny (it's a safety gate, not a prompt).\n+    assert observed[\"during_run\"](\"rm -rf /\", \"test\") == \"deny\"\n+\n+    assert observed[\"after_finally\"] is None, (\n+        \"Background review leaked its approval callback into the worker \"\n+        \"thread's TLS slot; a recycled thread-id could reuse it.\"\n+    )"]},{"file":"tests/tools/test_approval.py","hunks":["@@ -906,3 +906,62 @@ def test_safe_chmod_without_execute_not_flagged(self):\n         cmd = \"chmod +x script.sh\"\n         dangerous, _, _ = detect_dangerous_command(cmd)\n         assert dangerous is False\n+\n+\n+class TestFailClosedUnderPromptToolkit:\n+    \"\"\"Regression guard for #15216.\n+\n+    When prompt_toolkit owns the terminal and no approval callback is\n+    registered on the calling thread, prompt_dangerous_approval() must\n+    deny fast instead of falling through to the input() fallback -- which\n+    deadlocks because the user's keystrokes go to prompt_toolkit's raw-mode\n+    stdin capture, not to input().\n+    \"\"\"\n+\n+    def test_denies_when_prompt_toolkit_active_and_no_callback(self):\n+        import threading\n+        import prompt_toolkit.application.current as ptc\n+\n+        orig = ptc.get_app_or_none\n+        ptc.get_app_or_none = lambda: object()  # pretend a pt app is running\n+        result = []\n+        try:\n+            def run():\n+                result.append(\n+                    prompt_dangerous_approval(\n+                        \"rm -rf /\",\n+                        \"test danger\",\n+                        timeout_seconds=30,\n+                        approval_callback=None,\n+                    )\n+                )\n+\n+            t = threading.Thread(target=run, daemon=True)\n+            t.start()\n+            t.join(timeout=3)\n+            assert not t.is_alive(), (\n+                \"prompt_dangerous_approval deadlocked under prompt_toolkit \"\n+                \"with no callback -- fail-closed guard is broken\"\n+            )\n+            assert result == [\"deny\"]\n+        finally:\n+            ptc.get_app_or_none = orig\n+\n+    def test_callback_path_still_wins_over_guard(self):\n+        \"\"\"Guard must not short-circuit a valid callback.\"\"\"\n+        import prompt_toolkit.application.current as ptc\n+\n+        orig = ptc.get_app_or_none\n+        ptc.get_app_or_none = lambda: object()\n+        try:\n+            def cb(command, description, **kwargs):\n+                return \"once\"\n+\n+            result = prompt_dangerous_approval(\n+                \"rm -rf /\",\n+                \"test danger\",\n+                approval_callback=cb,\n+            )\n+            assert result == \"once\"\n+        finally:\n+            ptc.get_app_or_none = orig"]},{"file":"tools/approval.py","hunks":["@@ -536,6 +536,33 @@ def prompt_dangerous_approval(command: str, description: str,\n             logger.error(\"Approval callback failed: %s\", e, exc_info=True)\n             return \"deny\"\n \n+    # Fail-closed guard: if prompt_toolkit owns the terminal (interactive\n+    # CLI session) and no approval callback is registered on this thread,\n+    # the input() fallback below would spawn a daemon thread whose read\n+    # can never see Enter -- the user's keystrokes go to prompt_toolkit,\n+    # not input(), producing an invisible 60s deadlock (issue #15216).\n+    # Deny fast and log loudly instead so the caller can surface a real\n+    # error to the agent. Any thread that needs interactive approval must\n+    # install a callback via tools.terminal_tool.set_approval_callback()\n+    # before reaching this point (see delegate_tool.py, run_agent.py\n+    # _execute_tool_calls_concurrent / _spawn_background_review for the\n+    # established pattern).\n+    try:\n+        from prompt_toolkit.application.current import get_app_or_none\n+        if get_app_or_none() is not None:\n+            logger.warning(\n+                \"Dangerous-command approval requested on a thread with no \"\n+                \"approval callback while prompt_toolkit is active; denying \"\n+                \"to avoid stdin deadlock. command=%r description=%r\",\n+                command, description,\n+            )\n+            return \"deny\"\n+    except Exception:\n+        # prompt_toolkit not installed, or detection failed -- fall through\n+        # to the legacy input() path (safe in non-TUI contexts: scripts,\n+        # tests, sshd, etc.).\n+        pass\n+\n     os.environ[\"HERMES_SPINNER_PAUSE\"] = \"1\"\n     try:\n         while True:\n"]}]},{"kind":"pr","number":21067,"title":"fix(vision): bound autodetect analysis hangs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21037","number":21037},{"kind":"pr","value":"#21037","number":21037}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}