Skip to content

Commit 54a11ca

Browse files
authored
fix: improve interrupt responsiveness during concurrent tool execution and follow-up turns (NousResearch#10935)
Three targeted fixes for the 'agent stuck on terminal command' report: 1. **Concurrent tool wait loop now checks interrupts** (run_agent.py) The sequential path checked _interrupt_requested before each tool call, but the concurrent path's wait loop just blocked with 30s timeouts. Now polls every 5s and cancels pending futures on interrupt, giving already-running tools 3s to notice the per-thread interrupt signal. 2. **Cancelled concurrent tools get proper interrupt messages** (run_agent.py) When a concurrent tool is cancelled or didn't return a result due to interrupt, the tool result message says 'skipped due to user interrupt' instead of a generic error. 3. **Typing indicator fires before follow-up turn** (gateway/run.py) After an interrupt is acknowledged and the pending message dequeued, the gateway now sends a typing indicator before starting the recursive _run_agent call. This gives the user immediate visual feedback that the system is processing their new message (closing the perceived 'dead air' gap between the interrupt ack and the response). Reported by @_SushantSays.
1 parent 135e1a1 commit 54a11ca

3 files changed

Lines changed: 194 additions & 13 deletions

File tree

gateway/run.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9443,6 +9443,19 @@ async def _notify_long_running():
94439443
return result
94449444
next_message_id = getattr(pending_event, "message_id", None)
94459445

9446+
# Restart typing indicator so the user sees activity while
9447+
# the follow-up turn runs. The outer _process_message_background
9448+
# typing task is still alive but may be stale.
9449+
_followup_adapter = self.adapters.get(source.platform)
9450+
if _followup_adapter:
9451+
try:
9452+
await _followup_adapter.send_typing(
9453+
source.chat_id,
9454+
metadata=_status_thread_metadata,
9455+
)
9456+
except Exception:
9457+
pass
9458+
94469459
return await self._run_agent(
94479460
message=next_message,
94489461
context_prompt=context_prompt,

run_agent.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7549,24 +7549,50 @@ def _run_tool(index, tool_call, function_name, function_args):
75497549

75507550
# Wait for all to complete with periodic heartbeats so the
75517551
# gateway's inactivity monitor doesn't kill us during long
7552-
# concurrent tool batches.
7552+
# concurrent tool batches. Also check for user interrupts
7553+
# so we don't block indefinitely when the user sends /stop
7554+
# or a new message during concurrent tool execution.
75537555
_conc_start = time.time()
7556+
_interrupt_logged = False
75547557
while True:
75557558
done, not_done = concurrent.futures.wait(
7556-
futures, timeout=30.0,
7559+
futures, timeout=5.0,
75577560
)
75587561
if not not_done:
75597562
break
7563+
7564+
# Check for interrupt — the per-thread interrupt signal
7565+
# already causes individual tools (terminal, execute_code)
7566+
# to abort, but tools without interrupt checks (web_search,
7567+
# read_file) will run to completion. Cancel any futures
7568+
# that haven't started yet so we don't block on them.
7569+
if self._interrupt_requested:
7570+
if not _interrupt_logged:
7571+
_interrupt_logged = True
7572+
self._vprint(
7573+
f"{self.log_prefix}⚡ Interrupt: cancelling "
7574+
f"{len(not_done)} pending concurrent tool(s)",
7575+
force=True,
7576+
)
7577+
for f in not_done:
7578+
f.cancel()
7579+
# Give already-running tools a moment to notice the
7580+
# per-thread interrupt signal and exit gracefully.
7581+
concurrent.futures.wait(not_done, timeout=3.0)
7582+
break
7583+
75607584
_conc_elapsed = int(time.time() - _conc_start)
7561-
_still_running = [
7562-
parsed_calls[futures.index(f)][1]
7563-
for f in not_done
7564-
if f in futures
7565-
]
7566-
self._touch_activity(
7567-
f"concurrent tools running ({_conc_elapsed}s, "
7568-
f"{len(not_done)} remaining: {', '.join(_still_running[:3])})"
7569-
)
7585+
# Heartbeat every ~30s (6 × 5s poll intervals)
7586+
if _conc_elapsed > 0 and _conc_elapsed % 30 < 6:
7587+
_still_running = [
7588+
parsed_calls[futures.index(f)][1]
7589+
for f in not_done
7590+
if f in futures
7591+
]
7592+
self._touch_activity(
7593+
f"concurrent tools running ({_conc_elapsed}s, "
7594+
f"{len(not_done)} remaining: {', '.join(_still_running[:3])})"
7595+
)
75707596
finally:
75717597
if spinner:
75727598
# Build a summary message for the spinner stop
@@ -7578,8 +7604,11 @@ def _run_tool(index, tool_call, function_name, function_args):
75787604
for i, (tc, name, args) in enumerate(parsed_calls):
75797605
r = results[i]
75807606
if r is None:
7581-
# Shouldn't happen, but safety fallback
7582-
function_result = f"Error executing tool '{name}': thread did not return a result"
7607+
# Tool was cancelled (interrupt) or thread didn't return
7608+
if self._interrupt_requested:
7609+
function_result = f"[Tool execution cancelled — {name} was skipped due to user interrupt]"
7610+
else:
7611+
function_result = f"Error executing tool '{name}': thread did not return a result"
75837612
tool_duration = 0.0
75847613
else:
75857614
function_name, function_args, function_result, tool_duration, is_error = r
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for interrupt handling in concurrent tool execution."""
2+
3+
import concurrent.futures
4+
import threading
5+
import time
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def _isolate_hermes(tmp_path, monkeypatch):
13+
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
14+
(tmp_path / ".hermes").mkdir(exist_ok=True)
15+
16+
17+
def _make_agent(monkeypatch):
18+
"""Create a minimal AIAgent-like object with just the methods under test."""
19+
monkeypatch.setenv("OPENROUTER_API_KEY", "")
20+
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "")
21+
# Avoid full AIAgent init — just import the class and build a stub
22+
import run_agent as _ra
23+
24+
class _Stub:
25+
_interrupt_requested = False
26+
log_prefix = ""
27+
quiet_mode = True
28+
verbose_logging = False
29+
log_prefix_chars = 200
30+
_checkpoint_mgr = MagicMock(enabled=False)
31+
_subdirectory_hints = MagicMock()
32+
tool_progress_callback = None
33+
tool_start_callback = None
34+
tool_complete_callback = None
35+
_todo_store = MagicMock()
36+
_session_db = None
37+
valid_tool_names = set()
38+
_turns_since_memory = 0
39+
_iters_since_skill = 0
40+
_current_tool = None
41+
_last_activity = 0
42+
_print_fn = print
43+
44+
def _touch_activity(self, desc):
45+
self._last_activity = time.time()
46+
47+
def _vprint(self, msg, force=False):
48+
pass
49+
50+
def _safe_print(self, msg):
51+
pass
52+
53+
def _should_emit_quiet_tool_messages(self):
54+
return False
55+
56+
def _should_start_quiet_spinner(self):
57+
return False
58+
59+
def _has_stream_consumers(self):
60+
return False
61+
62+
stub = _Stub()
63+
# Bind the real methods
64+
stub._execute_tool_calls_concurrent = _ra.AIAgent._execute_tool_calls_concurrent.__get__(stub)
65+
stub._invoke_tool = MagicMock(side_effect=lambda *a, **kw: '{"ok": true}')
66+
return stub
67+
68+
69+
class _FakeToolCall:
70+
def __init__(self, name, args="{}", call_id="tc_1"):
71+
self.function = MagicMock(name=name, arguments=args)
72+
self.function.name = name
73+
self.id = call_id
74+
75+
76+
class _FakeAssistantMsg:
77+
def __init__(self, tool_calls):
78+
self.tool_calls = tool_calls
79+
80+
81+
def test_concurrent_interrupt_cancels_pending(monkeypatch):
82+
"""When _interrupt_requested is set during concurrent execution,
83+
the wait loop should exit early and cancelled tools get interrupt messages."""
84+
agent = _make_agent(monkeypatch)
85+
86+
# Create a tool that blocks until interrupted
87+
barrier = threading.Event()
88+
89+
original_invoke = agent._invoke_tool
90+
91+
def slow_tool(name, args, task_id, call_id=None):
92+
if name == "slow_one":
93+
# Block until the test sets the interrupt
94+
barrier.wait(timeout=10)
95+
return '{"slow": true}'
96+
return '{"fast": true}'
97+
98+
agent._invoke_tool = MagicMock(side_effect=slow_tool)
99+
100+
tc1 = _FakeToolCall("fast_one", call_id="tc_fast")
101+
tc2 = _FakeToolCall("slow_one", call_id="tc_slow")
102+
msg = _FakeAssistantMsg([tc1, tc2])
103+
messages = []
104+
105+
def _set_interrupt_after_delay():
106+
time.sleep(0.3)
107+
agent._interrupt_requested = True
108+
barrier.set() # unblock the slow tool
109+
110+
t = threading.Thread(target=_set_interrupt_after_delay)
111+
t.start()
112+
113+
agent._execute_tool_calls_concurrent(msg, messages, "test_task")
114+
t.join()
115+
116+
# Both tools should have results in messages
117+
assert len(messages) == 2
118+
# The interrupt was detected
119+
assert agent._interrupt_requested is True
120+
121+
122+
def test_concurrent_preflight_interrupt_skips_all(monkeypatch):
123+
"""When _interrupt_requested is already set before concurrent execution,
124+
all tools are skipped with cancellation messages."""
125+
agent = _make_agent(monkeypatch)
126+
agent._interrupt_requested = True
127+
128+
tc1 = _FakeToolCall("tool_a", call_id="tc_a")
129+
tc2 = _FakeToolCall("tool_b", call_id="tc_b")
130+
msg = _FakeAssistantMsg([tc1, tc2])
131+
messages = []
132+
133+
agent._execute_tool_calls_concurrent(msg, messages, "test_task")
134+
135+
assert len(messages) == 2
136+
assert "skipped due to user interrupt" in messages[0]["content"]
137+
assert "skipped due to user interrupt" in messages[1]["content"]
138+
# _invoke_tool should never have been called
139+
agent._invoke_tool.assert_not_called()

0 commit comments

Comments
 (0)