diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 356f72d889..2642025ae6 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -2021,3 +2021,45 @@ class TestOrchestratorEndToEnd(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestSubagentApprovalCallback(unittest.TestCase): + """fix(delegate): subagent threads must have approval callback set + to prevent deadlock when encountering dangerous commands.""" + + def test_subagent_executor_initializer_sets_approval_callback(self): + """ThreadPoolExecutor initializer must set approval callback in + worker thread so dangerous commands do not fall through to input().""" + from concurrent.futures import ThreadPoolExecutor + from tools.terminal_tool import ( + set_approval_callback as _set_cb, + _get_approval_callback, + ) + from tools.delegate_tool import _subagent_auto_approve + + seen_in_worker = [] + + def worker(): + seen_in_worker.append(_get_approval_callback()) + + with ThreadPoolExecutor( + max_workers=1, + initializer=_set_cb, + initargs=(_subagent_auto_approve,), + ) as executor: + executor.submit(worker).result() + + self.assertEqual( + seen_in_worker, + [_subagent_auto_approve], + "Worker thread must have _subagent_auto_approve set as approval " + "callback — without this, dangerous commands call input() and " + "deadlock the parent TUI" + ) + + def test_subagent_auto_approve_returns_once(self): + """_subagent_auto_approve must return once for any command.""" + from tools.delegate_tool import _subagent_auto_approve + + result = _subagent_auto_approve("rm -rf /tmp/test", "dangerous command") + self.assertEqual(result, "once") diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index fa0d00d744..23b2612daf 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional from toolsets import TOOLSETS from tools import file_state from utils import base_url_hostname, is_truthy_value +from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb # Tools that children must never have access to @@ -47,6 +48,19 @@ DELEGATE_BLOCKED_TOOLS = frozenset( ] ) +def _subagent_auto_approve(command: str, description: str, **kwargs) -> str: + """Auto-approve dangerous commands in subagent threads. + + Subagents run in ThreadPoolExecutor worker threads and cannot safely + call input() — it competes with the parent's prompt_toolkit TUI for + stdin causing a deadlock. This callback returns 'once' automatically + so subagents can proceed without blocking the parent UI. + """ + logger.warning( + "Subagent auto-approved dangerous command: %s (%s)", command, description + ) + return "once" + # Build a description fragment listing toolsets available for subagents. # Excludes toolsets where ALL tools are blocked, composite/platform toolsets # (hermes-* prefixed), and scenario toolsets. @@ -1174,7 +1188,11 @@ def _run_single_child( # Run child with a hard timeout to prevent indefinite blocking # when the child's API call or tool-level HTTP request hangs. child_timeout = _get_child_timeout() - _timeout_executor = ThreadPoolExecutor(max_workers=1) + _timeout_executor = ThreadPoolExecutor( + max_workers=1, + initializer=_set_subagent_approval_cb, + initargs=(_subagent_auto_approve,), + ) _child_future = _timeout_executor.submit( child.run_conversation, user_message=goal,