54aad0d670
The gateway turn-inactivity watchdog (gateway/run.py::_watch_gateway_turn_inactivity) abandons a turn once seconds_since_activity exceeds the inactivity timeout (default 30 min). Activity was only stamped when a tool started and when it completed, so a tool call that runs silently for 30+ minutes (quiet builds, long pytest suites, large downloads, network waits with no output) froze the clock and the watchdog hard-abandoned a turn that was still making progress, reaping the tool's processes mid-execution (issue #84491). Add a daemon-thread heartbeat inside _run_agent_tool_execution_middleware that touches agent._touch_activity every 30s while the tool is in flight, until the call returns. Both the sequential and concurrent execution paths funnel through this single middleware, so one heartbeat covers every tool. The thread is stopped in a try/finally so it always tears down even if execute() raises, and is never started when a guardrail/authorization block short-circuits before dispatch. A genuinely hung tool remains bounded by the tool layer's own timeouts (terminal default 180s, concurrent batch deadline ~420s), so the heartbeat only extends the turn's life while the call is legitimately running. Verified by an independent reviewer (no security/logic defects); 5 unit/integration tests pass on Python 3.12 (upstream CI). The 30-min gateway backstop remains for turns whose agent loop itself stalls.
271 lines
8.7 KiB
Python
271 lines
8.7 KiB
Python
"""Tests for the in-flight tool activity heartbeat (#84491).
|
|
|
|
The gateway's turn-inactivity watchdog
|
|
(``gateway/run.py::_watch_gateway_turn_inactivity``) abandons a turn once
|
|
``seconds_since_activity`` exceeds the inactivity timeout (default 30 min).
|
|
Activity was only stamped when a tool *started* and when it *completed*, so
|
|
a tool call that ran silently for 30+ minutes looked idle to the watchdog
|
|
and the turn was hard-abandoned mid-execution (processes reaped). The
|
|
the heartbeat in ``_run_agent_tool_execution_middleware`` stamps activity
|
|
periodically while a tool call is in flight.
|
|
"""
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_hermes(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
|
|
|
|
|
def _make_agent(monkeypatch):
|
|
"""Minimal AIAgent-like stub, mirroring test_start_order_gate.py."""
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "")
|
|
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "")
|
|
import run_agent as _ra
|
|
|
|
class _Stub:
|
|
_interrupt_requested = False
|
|
_interrupt_message = None
|
|
log_prefix = ""
|
|
quiet_mode = True
|
|
verbose_logging = False
|
|
log_prefix_chars = 200
|
|
_checkpoint_mgr = MagicMock(enabled=False)
|
|
tool_progress_callback = None
|
|
tool_start_callback = None
|
|
tool_complete_callback = None
|
|
tool_progress_mode = "off"
|
|
_todo_store = MagicMock()
|
|
_session_db = None
|
|
valid_tool_names = set()
|
|
_turns_since_memory = 0
|
|
_iters_since_skill = 0
|
|
_current_tool = None
|
|
_last_activity = 0.0
|
|
session_id = ""
|
|
_current_turn_id = ""
|
|
_current_api_request_id = ""
|
|
|
|
def __init__(self):
|
|
self._tool_worker_threads: set = set()
|
|
self._tool_worker_threads_lock = threading.Lock()
|
|
self._active_children_lock = threading.Lock()
|
|
|
|
def _touch_activity(self, desc):
|
|
self._last_activity = time.time()
|
|
|
|
def _vprint(self, msg, force=False):
|
|
pass
|
|
|
|
def _safe_print(self, msg):
|
|
pass
|
|
|
|
def _should_emit_quiet_tool_messages(self):
|
|
return False
|
|
|
|
def _should_start_quiet_spinner(self):
|
|
return False
|
|
|
|
def _has_stream_consumers(self):
|
|
return False
|
|
|
|
def _tool_result_content_for_active_model(self, name, result):
|
|
return result
|
|
|
|
def _record_file_mutation_result(self, *a, **kw):
|
|
pass
|
|
|
|
def _apply_pending_steer_to_tool_results(self, *a, **kw):
|
|
pass
|
|
|
|
stub = _Stub()
|
|
stub._subdirectory_hints = MagicMock()
|
|
stub._subdirectory_hints.check_tool_call = lambda *a, **kw: None
|
|
stub._flush_messages_to_session_db = lambda *a, **kw: None
|
|
stub._append_guardrail_observation = lambda name, result, *a, **kw: result
|
|
stub.interrupt = _ra.AIAgent.interrupt.__get__(stub)
|
|
stub.clear_interrupt = _ra.AIAgent.clear_interrupt.__get__(stub)
|
|
stub._guardrail_block_result = lambda d: json.dumps({"error": "blocked"})
|
|
return stub
|
|
|
|
|
|
def _slow_execute(delay: float = 0.25):
|
|
def _execute(next_args):
|
|
time.sleep(delay)
|
|
return json.dumps({"ok": True})
|
|
|
|
return _execute
|
|
|
|
|
|
def test_heartbeat_touches_periodically_and_stops():
|
|
"""The heartbeat thread touches activity on cadence, then exits on stop."""
|
|
import agent.tool_executor as te
|
|
|
|
touches: list = []
|
|
stop = threading.Event()
|
|
|
|
class _Agent:
|
|
def _touch_activity(self, desc):
|
|
touches.append(desc)
|
|
|
|
thread = threading.Thread(
|
|
target=te._run_tool_activity_heartbeat,
|
|
args=(_Agent(), stop, "tool running: terminal"),
|
|
kwargs={"interval": 0.05},
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
time.sleep(0.12)
|
|
stop.set()
|
|
thread.join(timeout=1.0)
|
|
|
|
assert not thread.is_alive(), "heartbeat thread did not exit on stop"
|
|
assert len(touches) >= 2, f"expected periodic touches, got {len(touches)}"
|
|
n = len(touches)
|
|
time.sleep(0.1)
|
|
assert len(touches) == n, "heartbeat kept touching after stop_event set"
|
|
|
|
|
|
def test_slow_tool_call_refreshes_activity_during_execution(monkeypatch):
|
|
"""A tool call running longer than one interval gets activity stamps.
|
|
|
|
Before the fix, only the start stamp ("executing tool: X") and the
|
|
completion stamp existed; a silent 30+ minute call left the clock
|
|
frozen and the gateway watchdog abandoned the turn.
|
|
"""
|
|
import agent.tool_executor as te
|
|
|
|
monkeypatch.setattr(te, "_TOOL_ACTIVITY_HEARTBEAT_INTERVAL_S", 0.05)
|
|
|
|
agent = _make_agent(monkeypatch)
|
|
agent._tool_guardrails = MagicMock(
|
|
before_call=lambda name, args: MagicMock(allows_execution=True)
|
|
)
|
|
touches: list = []
|
|
agent._touch_activity = lambda desc: touches.append(time.time())
|
|
|
|
result = te._run_agent_tool_execution_middleware(
|
|
agent,
|
|
function_name="terminal",
|
|
function_args={"command": "true"},
|
|
effective_task_id="task",
|
|
tool_call_id="tc1",
|
|
execute=_slow_execute(delay=0.25),
|
|
display_index=1,
|
|
)
|
|
|
|
assert json.loads(result.result) == {"ok": True}
|
|
|
|
# Start stamp + at least one heartbeat mid-call (0.25s run, 0.05s cadence).
|
|
assert len(touches) >= 3, f"expected mid-call heartbeats, got {len(touches)}"
|
|
spread = touches[-1] - touches[0]
|
|
assert spread >= 0.15, f"touches not spread across the call: {spread:.3f}s"
|
|
|
|
|
|
def test_fast_tool_call_does_not_leave_stray_heartbeat(monkeypatch):
|
|
"""A quick tool exits the heartbeat thread; no touches after return."""
|
|
import agent.tool_executor as te
|
|
|
|
monkeypatch.setattr(te, "_TOOL_ACTIVITY_HEARTBEAT_INTERVAL_S", 0.05)
|
|
|
|
agent = _make_agent(monkeypatch)
|
|
agent._tool_guardrails = MagicMock(
|
|
before_call=lambda name, args: MagicMock(allows_execution=True)
|
|
)
|
|
touches: list = []
|
|
agent._touch_activity = lambda desc: touches.append(time.time())
|
|
|
|
te._run_agent_tool_execution_middleware(
|
|
agent,
|
|
function_name="terminal",
|
|
function_args={"command": "true"},
|
|
effective_task_id="task",
|
|
tool_call_id="tc1",
|
|
execute=_slow_execute(delay=0.02),
|
|
display_index=1,
|
|
)
|
|
|
|
n = len(touches)
|
|
time.sleep(0.12) # several heartbeat intervals
|
|
assert len(touches) == n, "heartbeat thread kept running after tool returned"
|
|
|
|
|
|
def test_heartbeat_stops_when_execute_raises(monkeypatch):
|
|
"""If the tool call raises, the heartbeat thread still stops (no leak)."""
|
|
|
|
import agent.tool_executor as te
|
|
|
|
monkeypatch.setattr(te, "_TOOL_ACTIVITY_HEARTBEAT_INTERVAL_S", 0.05)
|
|
|
|
agent = _make_agent(monkeypatch)
|
|
agent._tool_guardrails = MagicMock(
|
|
before_call=lambda name, args: MagicMock(allows_execution=True)
|
|
)
|
|
touches: list = []
|
|
agent._touch_activity = lambda desc: touches.append(time.time())
|
|
|
|
def _boom(next_args):
|
|
raise RuntimeError("tool exploded")
|
|
|
|
with pytest.raises(RuntimeError):
|
|
te._run_agent_tool_execution_middleware(
|
|
agent,
|
|
function_name="terminal",
|
|
function_args={"command": "true"},
|
|
effective_task_id="task",
|
|
tool_call_id="tc1",
|
|
execute=_boom,
|
|
display_index=1,
|
|
)
|
|
|
|
n = len(touches)
|
|
time.sleep(0.12) # several heartbeat intervals
|
|
assert len(touches) == n, "heartbeat thread kept running after execute() raised"
|
|
|
|
|
|
def test_concurrent_tool_call_heartbeat(monkeypatch):
|
|
"""Concurrent execution also stamps activity via the shared chokepoint."""
|
|
import agent.tool_executor as te
|
|
|
|
monkeypatch.setattr(te, "_TOOL_ACTIVITY_HEARTBEAT_INTERVAL_S", 0.05)
|
|
|
|
agent = _make_agent(monkeypatch)
|
|
agent._tool_guardrails = MagicMock(
|
|
before_call=lambda name, args: MagicMock(allows_execution=True)
|
|
)
|
|
touches: list = []
|
|
agent._touch_activity = lambda desc: touches.append(time.time())
|
|
|
|
agent._execute_tool_calls_concurrent = (
|
|
__import__("run_agent").AIAgent._execute_tool_calls_concurrent.__get__(agent)
|
|
)
|
|
|
|
class _FakeToolCall:
|
|
def __init__(self, name, call_id):
|
|
self.function = MagicMock(name=name, arguments="{}")
|
|
self.function.name = name
|
|
self.id = call_id
|
|
|
|
class _FakeAssistantMsg:
|
|
def __init__(self, tool_calls):
|
|
self.tool_calls = tool_calls
|
|
|
|
def _invoke(name, *a, **kw):
|
|
time.sleep(0.25)
|
|
return json.dumps({"ok": name})
|
|
|
|
agent._invoke_tool = MagicMock(side_effect=_invoke)
|
|
|
|
msg = _FakeAssistantMsg([_FakeToolCall("tool_a", "tc_a")])
|
|
messages: list = []
|
|
agent._execute_tool_calls_concurrent(msg, messages, "task")
|
|
|
|
assert len(touches) >= 3, f"expected mid-call heartbeats, got {len(touches)}"
|