{"repo":"NousResearch/hermes-agent","public_id":"13041","display_label":"Subagent delegation timeout configurability and fixes","summary":"These issues and PRs concern timeout behavior in delegate_task and delegate_subagent, including requests to make timeouts configurable, bugs where subagents idle-timeout prematurely, and fixes to propagate child activity to parent heartbeat detection.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15183},"resolution":{"state":"partially_resolved","by":"pr-15183"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T14:25:20+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.308},"fix_edges":[{"issue_number":13041,"pr_number":15183,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13041,"title":"Bug: delegate_task subagents can idle-timeout without completing","url":null,"state":"closed"},{"kind":"issue","number":42861,"title":"[Feature]: Increase or Make Configurable the Subagent Timeout Limit","url":null,"state":"closed"},{"kind":"issue","number":64089,"title":"delegate_subagent and delegate_task: timeout should be a configurable parameter","url":null,"state":"closed"},{"kind":"pr","number":15183,"title":"fix(delegate): tool-activity-aware heartbeat stale detection","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_delegate.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"tests/tools/test_delegate.py","hunks":["@@ -1319,6 +1319,112 @@ def slow_run(**kwargs):\n             any(\"API call #5 completed\" in desc for desc in touch_calls),\n             f\"Heartbeat should include last_activity_desc: {touch_calls}\")\n \n+    def test_heartbeat_does_not_trip_idle_stale_while_inside_tool(self):\n+        \"\"\"A long-running tool (no iteration advance, but current_tool set)\n+        must not be flagged stale at the idle threshold.\n+\n+        Bug #13041: when a child is legitimately busy inside a slow tool\n+        (terminal command, browser fetch), api_call_count does not advance.\n+        The previous stale check treated this as idle and stopped the\n+        heartbeat after 5 cycles (~150s), letting the gateway kill the\n+        session. The fix uses a much higher in-tool threshold and only\n+        applies the tight idle threshold when current_tool is None.\n+        \"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        # Child is stuck inside a single terminal call for the whole run.\n+        # api_call_count never advances, current_tool is always set.\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": \"terminal\",\n+            \"api_call_count\": 1,\n+            \"max_iterations\": 50,\n+            \"last_activity_desc\": \"executing tool: terminal\",\n+        }\n+\n+        def slow_run(**kwargs):\n+            # Long enough to exceed the OLD idle threshold (5 cycles) at\n+            # the patched interval, but shorter than the new in-tool\n+            # threshold.\n+            time.sleep(0.4)\n+            return {\"final_response\": \"done\", \"completed\": True, \"api_calls\": 1}\n+\n+        child.run_conversation.side_effect = slow_run\n+\n+        # Patch both the interval AND the idle ceiling so the test proves\n+        # the in-tool branch takes effect: with a 0.05s interval and the\n+        # default _HEARTBEAT_STALE_CYCLES_IDLE=5, the old behavior would\n+        # trip after 0.25s and stop firing. We should see heartbeats\n+        # continuing through the full 0.4s run.\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            _run_single_child(\n+                task_index=0,\n+                goal=\"Test long-running tool\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        # With the old idle threshold (5 cycles = 0.25s), touch_calls\n+        # would cap at ~5. With the in-tool threshold (20 cycles = 1.0s),\n+        # we should see substantially more heartbeats over 0.4s.\n+        self.assertGreater(\n+            len(touch_calls), 6,\n+            f\"Heartbeat stopped too early while child was inside a tool; \"\n+            f\"got {len(touch_calls)} touches over 0.4s at 0.05s interval\",\n+        )\n+\n+    def test_heartbeat_still_trips_idle_stale_when_no_tool(self):\n+        \"\"\"A wedged child with no current_tool still trips the idle threshold.\n+\n+        Regression guard: the fix for #13041 must not disable stale\n+        detection entirely. A child that's hung between turns (no tool\n+        running, no iteration progress) must still stop touching the\n+        parent so the gateway timeout can fire.\n+        \"\"\"\n+        from tools.delegate_tool import _run_single_child\n+\n+        parent = _make_mock_parent()\n+        touch_calls = []\n+        parent._touch_activity = lambda desc: touch_calls.append(desc)\n+\n+        child = MagicMock()\n+        # Wedged child: no tool running, iteration frozen.\n+        child.get_activity_summary.return_value = {\n+            \"current_tool\": None,\n+            \"api_call_count\": 3,\n+            \"max_iterations\": 50,\n+            \"last_activity_desc\": \"waiting for API response\",\n+        }\n+\n+        def slow_run(**kwargs):\n+            time.sleep(0.6)\n+            return {\"final_response\": \"done\", \"completed\": True, \"api_calls\": 3}\n+\n+        child.run_conversation.side_effect = slow_run\n+\n+        # At interval 0.05s, idle threshold (5 cycles) trips at ~0.25s.\n+        # We should see the heartbeat stop firing well before 0.6s.\n+        with patch(\"tools.delegate_tool._HEARTBEAT_INTERVAL\", 0.05):\n+            _run_single_child(\n+                task_index=0,\n+                goal=\"Test wedged child\",\n+                child=child,\n+                parent_agent=parent,\n+            )\n+\n+        # With idle threshold=5 + interval=0.05s, touches should cap\n+        # around 5. Bound loosely to avoid timing flakes.\n+        self.assertLess(\n+            len(touch_calls), 9,\n+            f\"Idle stale detection did not fire: got {len(touch_calls)} \"\n+            f\"touches over 0.6s — expected heartbeat to stop after \"\n+            f\"~5 stale cycles\",\n+        )\n+\n \n class TestDelegationReasoningEffort(unittest.TestCase):\n     \"\"\"Tests for delegation.reasoning_effort config override.\"\"\""]},{"file":"tools/delegate_tool.py","hunks":["@@ -411,9 +411,15 @@ def _preserve_parent_mcp_toolsets(\n DEFAULT_MAX_ITERATIONS = 50\n DEFAULT_CHILD_TIMEOUT = 600  # seconds before a child agent is considered stuck\n _HEARTBEAT_INTERVAL = 30  # seconds between parent activity heartbeats during delegation\n-_HEARTBEAT_STALE_CYCLES = (\n-    5  # mark child stale after this many heartbeats with no iteration progress\n-)\n+# Stale-heartbeat thresholds. A child with no API-call progress is either:\n+#   - idle between turns (no current_tool) — probably stuck on a slow API call\n+#   - inside a tool (current_tool set) — probably running a legitimately long\n+#     operation (terminal command, web fetch, large file read)\n+# The idle ceiling stays tight so genuinely stuck children don't mask the gateway\n+# timeout. The in-tool ceiling is much higher so legit long-running tools get\n+# time to finish; child_timeout_seconds (default 600s) is still the hard cap.\n+_HEARTBEAT_STALE_CYCLES_IDLE = 5  # 5 * 30s = 150s idle between turns → stale\n+_HEARTBEAT_STALE_CYCLES_IN_TOOL = 20  # 20 * 30s = 600s stuck on same tool → stale\n DEFAULT_TOOLSETS = [\"terminal\", \"file\", \"web\"]\n \n ","@@ -1057,7 +1063,11 @@ def _run_single_child(\n     # Without this, the parent's _last_activity_ts freezes when delegate_task\n     # starts and the gateway eventually kills the agent for \"no activity\".\n     _heartbeat_stop = threading.Event()\n-    _last_seen_iter = [0]  # mutable container for heartbeat stale detection\n+    # Stale detection: track the child's (tool, iteration) pair across\n+    # heartbeat cycles. If neither advances, count the cycle as stale.\n+    # Different thresholds for idle vs in-tool (see _HEARTBEAT_STALE_CYCLES_*).\n+    _last_seen_iter = [0]\n+    _last_seen_tool = [None]  # type: list\n     _stale_count = [0]\n \n     def _heartbeat_loop():","@@ -1075,22 +1085,38 @@ def _heartbeat_loop():\n                 child_iter = child_summary.get(\"api_call_count\", 0)\n                 child_max = child_summary.get(\"max_iterations\", 0)\n \n-                # Stale detection: if iteration count hasn't advanced,\n-                # increment stale counter.  After N cycles with no\n-                # progress, stop masking the hang so the gateway\n-                # inactivity timeout can fire as a last resort.\n-                if child_iter <= _last_seen_iter[0]:\n-                    _stale_count[0] += 1\n-                else:\n+                # Stale detection: count cycles where neither the iteration\n+                # count nor the current_tool advances. A child running a\n+                # legitimately long-running tool (terminal command, web\n+                # fetch) keeps current_tool set but doesn't advance\n+                # api_call_count — we don't want that to look stale at the\n+                # idle threshold.\n+                iter_advanced = child_iter > _last_seen_iter[0]\n+                tool_changed = child_tool != _last_seen_tool[0]\n+                if iter_advanced or tool_changed:\n                     _last_seen_iter[0] = child_iter\n+                    _last_seen_tool[0] = child_tool\n                     _stale_count[0] = 0\n+                else:\n+                    _stale_count[0] += 1\n \n-                if _stale_count[0] >= _HEARTBEAT_STALE_CYCLES:\n+                # Pick threshold based on whether the child is currently\n+                # inside a tool call. In-tool threshold is high enough to\n+                # cover legitimately slow tools; idle threshold stays\n+                # tight so the gateway timeout can fire on a truly wedged\n+                # child.\n+                stale_limit = (\n+                    _HEARTBEAT_STALE_CYCLES_IN_TOOL\n+                    if child_tool\n+                    else _HEARTBEAT_STALE_CYCLES_IDLE\n+                )\n+                if _stale_count[0] >= stale_limit:\n                     logger.warning(\n-                        \"Subagent %d appears stale (no iteration progress \"\n-                        \"for %d heartbeat cycles) — stopping heartbeat\",\n+                        \"Subagent %d appears stale (no progress for %d \"\n+                        \"heartbeat cycles, tool=%s) — stopping heartbeat\",\n                         task_index,\n                         _stale_count[0],\n+                        child_tool or \"<none>\",\n                     )\n                     break  # stop touching parent, let gateway timeout fire\n \n"]}]},{"kind":"pr","number":43161,"title":"feat(delegation): add per-delegation timeout parameter to delegate_task","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bba9b519aae8bf5e734a13921534fb99050dd562","number":0},{"kind":"pr","value":"#42861","number":42861}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}