# NousResearch/hermes-agent · Subagent delegation hangs: timeouts, progress, and background mode

## Open now

- 1 issue, 1 PR open on GitHub.
- #45496: \[Bug\]: delegate_task blocks parent session silently with no progress output when children hang on tool calls
- PR #46186: fix(delegate): surface progress while children are waiting

Most members concern delegate_task blocking the parent turn when a child subagent's run_conversation() stalls or is slow. They split between timeout/stale-detection and progress-visibility changes and proposals for non-blocking background (fire-and-forget) delegation.

## TL;DR

- 13 member(s): 1 open issue(s), 5 closed issue(s), 1 open PR(s), 1 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #13770. Verify it addresses your case.
- **Recommended:** read PR #13770 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-13770`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.11`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #45496: \[Bug\]: delegate_task blocks parent session silently with no progress output when children hang on tool calls

## Closed / duplicate issues

- #1599: feat(delegation): add background=true mode for fire-and-forget subagent tasks
- #5586: feat: non-blocking background agent delegation (async_delegation toolset)
- #10678: delegate_task can hang indefinitely while child run_conversation stalls with lingering CLOSE-WAIT sockets
- #13768: \[Delegation\] Subagent \`run_conversation()\` has no timeout — can block indefinitely on slow API/network
- #47302: Non-blocking subagent delegation: a stuck/slow child freezes the whole parent turn

## Prior & competing PRs

- **#1600: feat(delegation): add background=true mode for fire-and-forget subagent tasks**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#1599`
- **#5587: feat(async-delegation): non-blocking background agent tools**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#13770: fix(delegation): add hard timeout and stale detection for subagent execution**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `tools/delegate_tool.py`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -333,6 +333,7 @@
         "[redacted:email]": "Aslaaen",
         "[redacted:email]": "pinion05",
         "105142614+VTRiot@users.noreply.github.com": "VTRiot",
    +    "[redacted:email]": "iamagenius00",
     }
     
     
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -23,7 +23,7 @@
     import os
     import threading
     import time
    -from concurrent.futures import ThreadPoolExecutor, as_completed
    +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, as_completed
     from typing import Any, Dict, List, Optional
     
     from toolsets import TOOLSETS
    ```

    ```diff
    @@ -112,6 +112,31 @@ def _get_max_concurrent_children() -> int:
         return _DEFAULT_MAX_CONCURRENT_CHILDREN
     
     
    +def _get_child_timeout() -> float:
    +    """Read delegation.child_timeout_seconds from config.
    +
    +    Returns the number of seconds a single child agent is allowed to run
    +    before being considered stuck.  Default: 300 s (5 minutes).
    +    """
    +    cfg = _load_config()
    +    val = cfg.get("child_timeout_seconds")
    +    if val is not None:
    +        try:
    +            return max(30.0, float(val))
    +        except (TypeError, ValueError):
    +            logger.warning(
    +                "delegation.child_timeout_seconds=%r is not a valid number; "
    +                "using default %d", val, DEFAULT_CHILD_TIMEOUT,
    +            )
    +    env_val = os.getenv("DELEGATION_CHILD_TIMEOUT_SECONDS")
    +    if env_val:
    +        try:
    +            return max(30.0, float(env_val))
    +        except (TypeError, ValueError):
    +            pass
    +    return float(DEFAULT_CHILD_TIMEOUT)
    +
    +
     def _get_max_spawn_depth() -> int:
         """Read delegation.max_spawn_depth from config, clamped to [1, 3].
     
    ```

    ```diff
    @@ -165,7 +190,9 @@ def _get_orchestrator_enabled() -> bool:
     
     
     DEFAULT_MAX_ITERATIONS = 50
    +DEFAULT_CHILD_TIMEOUT = 300  # seconds before a child agent is considered stuck
     _HEARTBEAT_INTERVAL = 30  # seconds between parent activity heartbeats during delegation
    +_HEARTBEAT_STALE_CYCLES = 5  # mark child stale after this many heartbeats with no iteration progress
     DEFAULT_TOOLSETS = ["terminal", "file", "web"]
     
     
    ```

    ```diff
    @@ -689,6 +716,8 @@ def _run_single_child(
         # Without this, the parent's _last_activity_ts freezes when delegate_task
         # starts and the gateway eventually kills the agent for "no activity".
         _heartbeat_stop = threading.Event()
    +    _last_seen_iter = [0]  # mutable container for heartbeat stale detection
    +    _stale_count = [0]
     
         def _heartbeat_loop():
             while not _heartbeat_stop.wait(_HEARTBEAT_INTERVAL):
    ```

    ```diff
    @@ -704,6 +733,25 @@ def _heartbeat_loop():
                     child_tool = child_summary.get("current_tool")
                     child_iter = child_summary.get("api_call_count", 0)
                     child_max = child_summary.get("max_iterations", 0)
    +
    +                # Stale detection: if iteration count hasn't advanced,
    +                # increment stale counter.  After N cycles with no
    +                # progress, stop masking the hang so the gateway
    +                # inactivity timeout can fire as a last resort.
    +                if child_iter <= _last_seen_iter[0]:
    +                    _stale_count[0] += 1
    +                else:
    +                    _last_seen_iter[0] = child_iter
    +                    _stale_count[0] = 0
    +
    +                if _stale_count[0] >= _HEARTBEAT_STALE_CYCLES:
    +                    logger.warning(
    +                        "Subagent %d appears stale (no iteration progress "
    +                        "for %d heartbeat cycles) — stopping heartbeat",
    +                        task_index, _stale_count[0],
    +                    )
    +                    break  # stop touching parent, let gateway timeout fire
    +
                     if child_tool:
                         desc = (f"delegate_task: subagent running {child_tool} "
                                 f"(iteration {child_iter}/{child_max})")
    ```

    ```diff
    @@ -744,7 +792,63 @@ def _heartbeat_loop():
                 if parent_task_id else []
             )
     
    -        result = child.run_conversation(user_message=goal, task_id=child_task_id)
    +        # 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)
    +        _child_future = _timeout_executor.submit(
    +            child.run_conversation, user_message=goal, task_id=child_task_id,
    +        )
    +        try:
    +            result = _child_future.result(timeout=child_timeout)
    +        except Exception as _timeout_exc:
    +            # Signal the child to stop so its thread can exit cleanly.
    +            try:
    +                if hasattr(child, 'interrupt'):
    +                    child.interrupt()
    +                elif hasattr(child, '_interrupt_requested'):
    +                    child._interrupt_requested = True
    +            except Exception:
    +                pass
    +
    +            is_timeout = isinstance(_timeout_exc, (FuturesTimeoutError, TimeoutError))
    +            duration = round(time.monotonic() - child_start, 2)
    +            logger.warning(
    +                "Subagent %d %s after %.1fs",
    +                task_index,
    +                "timed out" if is_timeout else f"raised {type(_timeout_exc).__name__}",
    +                duration,
    +            )
    +
    +            if child_progress_cb:
    +                try:
    +                    child_progress_cb(
    +                        "subagent.complete",
    +                        preview=f"Timed out after {duration}s" if is_timeout else str(_timeout_exc),
    +                        status="timeout" if is_timeout else "error",
    +                        duration_seconds=duration,
    +                        summary="",
    +                    )
    +                except Exception:
    +                    pass
    +
    +            return {
    +                "task_index": task_index,
    +                "status": "timeout" if is_timeout else "error",
    +                "summary": None,
    +                "error": (
    +                    f"Subagent timed out after {child_timeout}s with no response. "
    +                    "The child may be stuck on a slow API call or unresponsive network request."
    +                ) if is_timeout else str(_timeout_exc),
    +                "exit_reason": "timeout" if is_timeout else "error",
    +                "api_calls": 0,
    +                "duration_seconds": duration,
    +                "_child_role": getattr(child, "_delegate_role", None),
    +            }
    +        finally:
    +            # Shut down executor without waiting — if the child thread
    +            # is stuck on blocking I/O, wait=True would hang forever.
    +            _timeout_executor.shutdown(wait=False)
     
             # Flush any remaining batched progress to gateway
             if child_progress_cb and hasattr(child_progress_cb, '_flush'):
    ```
- **#2567: feat(delegation): add background=true for fire-and-forget subagent tasks**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#2570: feat(delegation): add background=true for fire-and-forget subagent tasks**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#13797: fix(delegate): add timeout to subagent run_conversation() (Closes #13768)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#13770`, `#13768`
- **#46186: fix(delegate): surface progress while children are waiting**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `cd9f5cc6718050819df17d4a3c07e199faf6e284`
  - files: `tests/tools/test_delegate.py`, `tools/delegate_tool.py`

## Fix status

- Merged to main in PR #13770 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-22`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
