Vision auto-detect and CLI input hang fixes
NousResearch/hermes-agent · cluster 15194 · agent
Data as of
Nothing open on GitHub. Fix merged in #16574. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #16574. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #16574. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#16574 fix(approval): close prompt_toolkit deadlock vectors (#15216)
files:
run_agent.py,scripts/release.py,tests/run_agent/test_background_review.py,tests/tools/test_approval.py,tools/approval.pyShow the diff
-
run_agent.py@@ -74,6 +74,12 @@ check_toolset_requirements, ) from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env +from tools.terminal_tool import ( + set_approval_callback as _set_approval_callback, + set_sudo_password_callback as _set_sudo_password_callback, + _get_approval_callback, + _get_sudo_password_callback, +) from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget from tools.interrupt import set_interrupt as _set_interrupt from tools.browser_tool import cleanup_browser @@ -3241,6 +3247,21 @@ def _spawn_background_review( def _run_review(): import contextlib + # Install a non-interactive approval callback on this worker + # thread so any dangerous-command guard the review agent trips + # resolves to "deny" instead of falling back to input() -- which + # deadlocks against the parent's prompt_toolkit TUI (#15216). + # Same pattern as _subagent_auto_deny in tools/delegate_tool.py. + def _bg_review_auto_deny(command, description, **kwargs): + logger.warning( + "Background review auto-denied dangerous command: %s (%s)", + command, description, + ) + return "deny" + try: + _set_approval_callback(_bg_review_auto_deny) + except Exception: + pass review_agent = None try: with open(os.devnull, "w") as _devnull, \ @@ -3322,6 +3343,12 @@ def _run_review(): review_agent.close() except Exception: pass + # Clear the approval callback on this bg-review thread so a + # recycled thread-id doesn't inherit a stale reference. + try: + _set_approval_callback(None) + except Exception: + pass t = threading.Thread(target=_run_review, daemon=True, name="bg-review") t.start() @@ -8469,6 +8496,14 @@ def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effe self._current_tool = tool_names_str self._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") + # Capture CLI callbacks from the agent thread so worker threads can + # register them locally. Without this, _get_approval_callback() in + # terminal_tool returns None in ThreadPoolExecutor workers, causing + # the dangerous-command prompt to fall back to input() — which + # deadlocks against prompt_toolkit's raw terminal mode (#13617). + _parent_approval_cb = _get_approval_callback() + _parent_sudo_cb = _get_sudo_password_callback() + def _run_tool(index, tool_call, function_name, function_args): """Worker function executed in a thread.""" # 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): set_activity_callback(self._touch_activity) except Exception: pass + # Propagate approval/sudo callbacks to this worker thread. + # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr). + if _parent_approval_cb is not None: + try: + _set_approval_callback(_parent_approval_cb) + except Exception: + pass + if _parent_sudo_cb is not None: + try: + _set_sudo_password_callback(_parent_sudo_cb) + except Exception: + pass start = time.time() try: 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): _set_interrupt(False, _worker_tid) except Exception: pass + # Clear thread-local callbacks so a recycled worker thread + # doesn't hold stale references to a disposed CLI instance. + try: + _set_approval_callback(None) + _set_sudo_password_callback(None) + except Exception: + pass # Start spinner for CLI mode (skip when TUI handles tool progress) spinner = None -
scripts/release.py@@ -553,6 +553,7 @@ "[redacted:email]": "chen1749144759", "[redacted:email]": "MorAlekss", "[redacted:email]": "ash", + "[redacted:email]": "andrewhosf", } -
tests/run_agent/test_background_review.py@@ -71,3 +71,59 @@ def close(self): "shutdown_memory_provider", "close", ] + + +def test_background_review_installs_auto_deny_approval_callback(monkeypatch): + """Regression guard for #15216. + + The background review thread must install a non-interactive approval + callback. If it doesn't, any dangerous-command guard the review agent + trips falls back to input() on a daemon thread, which deadlocks against + the parent's prompt_toolkit TUI. + """ + import tools.terminal_tool as tt + + observed: dict = {"during_run": "<unread>", "after_finally": "<unread>"} + + class FakeReviewAgent: + def __init__(self, **kwargs): + self._session_messages = [] + + def run_conversation(self, **kwargs): + # Capture what the callback looks like mid-run. It must be + # a callable (the auto-deny) -- not None. + observed["during_run"] = tt._get_approval_callback() + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent) + monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread) + + # Start from a clean slot. + tt.set_approval_callback(None) + agent = _bare_agent() + + AIAgent._spawn_background_review( + agent, + messages_snapshot=[{"role": "user", "content": "hello"}], + review_memory=True, + ) + + observed["after_finally"] = tt._get_approval_callback() + + assert callable(observed["during_run"]), ( + "Background review did not install an approval callback on its " + "worker thread; dangerous-command prompts will deadlock against " + "the parent TUI (#15216)." + ) + # The installed callback must deny (it's a safety gate, not a prompt). + assert observed["during_run"]("rm -rf /", "test") == "deny" + + assert observed["after_finally"] is None, ( + "Background review leaked its approval callback into the worker " + "thread's TLS slot; a recycled thread-id could reuse it." + ) -
tests/tools/test_approval.py@@ -906,3 +906,62 @@ def test_safe_chmod_without_execute_not_flagged(self): cmd = "chmod +x script.sh" dangerous, _, _ = detect_dangerous_command(cmd) assert dangerous is False + + +class TestFailClosedUnderPromptToolkit: + """Regression guard for #15216. + + When prompt_toolkit owns the terminal and no approval callback is + registered on the calling thread, prompt_dangerous_approval() must + deny fast instead of falling through to the input() fallback -- which + deadlocks because the user's keystrokes go to prompt_toolkit's raw-mode + stdin capture, not to input(). + """ + + def test_denies_when_prompt_toolkit_active_and_no_callback(self): + import threading + import prompt_toolkit.application.current as ptc + + orig = ptc.get_app_or_none + ptc.get_app_or_none = lambda: object() # pretend a pt app is running + result = [] + try: + def run(): + result.append( + prompt_dangerous_approval( + "rm -rf /", + "test danger", + timeout_seconds=30, + approval_callback=None, + ) + ) + + t = threading.Thread(target=run, daemon=True) + t.start() + t.join(timeout=3) + assert not t.is_alive(), ( + "prompt_dangerous_approval deadlocked under prompt_toolkit " + "with no callback -- fail-closed guard is broken" + ) + assert result == ["deny"] + finally: + ptc.get_app_or_none = orig + + def test_callback_path_still_wins_over_guard(self): + """Guard must not short-circuit a valid callback.""" + import prompt_toolkit.application.current as ptc + + orig = ptc.get_app_or_none + ptc.get_app_or_none = lambda: object() + try: + def cb(command, description, **kwargs): + return "once" + + result = prompt_dangerous_approval( + "rm -rf /", + "test danger", + approval_callback=cb, + ) + assert result == "once" + finally: + ptc.get_app_or_none = orig -
tools/approval.py@@ -536,6 +536,33 @@ def prompt_dangerous_approval(command: str, description: str, logger.error("Approval callback failed: %s", e, exc_info=True) return "deny" + # Fail-closed guard: if prompt_toolkit owns the terminal (interactive + # CLI session) and no approval callback is registered on this thread, + # the input() fallback below would spawn a daemon thread whose read + # can never see Enter -- the user's keystrokes go to prompt_toolkit, + # not input(), producing an invisible 60s deadlock (issue #15216). + # Deny fast and log loudly instead so the caller can surface a real + # error to the agent. Any thread that needs interactive approval must + # install a callback via tools.terminal_tool.set_approval_callback() + # before reaching this point (see delegate_tool.py, run_agent.py + # _execute_tool_calls_concurrent / _spawn_background_review for the + # established pattern). + try: + from prompt_toolkit.application.current import get_app_or_none + if get_app_or_none() is not None: + logger.warning( + "Dangerous-command approval requested on a thread with no " + "approval callback while prompt_toolkit is active; denying " + "to avoid stdin deadlock. command=%r description=%r", + command, description, + ) + return "deny" + except Exception: + # prompt_toolkit not installed, or detection failed -- fall through + # to the legacy input() path (safe in non-TUI contexts: scripts, + # tests, sshd, etc.). + pass + os.environ["HERMES_SPINNER_PAUSE"] = "1" try: while True:
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#21067 fix(vision): bound autodetect analysis hangs · done elsewhere →
#21037
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.39: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #16574
- Closed issues
- 2
- Attempts
- 1 PR closed without merging
- Fix link checked
- 27 Apr 2026