# NousResearch/hermes-agent · Delegate task subagent reliability and verification

## Open now

- 3 issues, 2 PRs open on GitHub.
- #16357: delegate_task: subagent side-effect verification — structural mechanism beyond the tool-description warning
- #20807: delegate_task may return completed with unrelated summary and no verifiable side effects when using ACP child agents
- #63887: Async delegation: child reports 'completed' with fabricated success when the goal requires toolsets it cannot have
- PR #63897: fix(delegate): add capability precheck to prevent fabricated success when goal requires unavailable tools
- PR #63899: fix(delegate): surface child's resolved toolsets in async completion block (#63887)

These items address problems with delegate_task and child agents: fabricated success reports, missing tool availability, side-effect verification, and exposing child tool history and completed toolsets for inspection.

## TL;DR

- 14 member(s): 3 open issue(s), 3 closed issue(s), 2 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #53904. Verify it addresses your case.
- **Recommended:** read PR #53904 (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-53904,pr-72403`. 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

- #16357: delegate_task: subagent side-effect verification — structural mechanism beyond the tool-description warning
- #20807: delegate_task may return completed with unrelated summary and no verifiable side effects when using ACP child agents
- #63887: Async delegation: child reports 'completed' with fabricated success when the goal requires toolsets it cannot have

## Closed / duplicate issues

- #19144: \[Feature\] Subagent output verification layer
- #21658: Subagent tool delegation inconsistent — file/terminal tools missing for children despite toolsets parameter
- #61974: Feature: expose tool_call_history in subagent_stop hook payload

## Prior & competing PRs

- **#20861: fix(delegate): detect ACP abort and unrelated summaries as failed**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#64223`
- **#53904: fix(tools): suppress transient check_fn flakes so subagents keep file/terminal tools**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `scripts/release.py`, `tests/tools/test_terminal_tool_requirements.py`, `tools/registry.py`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -47,6 +47,7 @@
     AUTHOR_MAP = {
         "[redacted:email]": "konsisumer",  # PR #19608 salvage (read-modify-write merge in write_credential_pool to preserve concurrently-added credentials; #19566)
         "[redacted:email]": "linyubin",  # PR #50228 salvage (eager fallback on persistent transport timeout/overloaded; #22277)
    +    "[redacted:email]": "djstunami",  # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304)
         "[redacted:email]": "dalenguyen",  # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473)
         "[redacted:email]": "Blaryxoff",  # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)
         "[redacted:email]": "DiamondEyesFox",  # PR #53351 salvage (rebaseline in-place compression flushes to prevent duplicate compacted rows; #9096)
    ```
  - `tests/tools/test_terminal_tool_requirements.py`

    ```diff
    @@ -64,3 +64,135 @@ def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeyp
     
             assert "terminal" in names
             assert "execute_code" in names
    +
    +
    +class TestCheckFnTransientFailureSuppression:
    +    """The check_fn TTL cache should absorb transient probe failures.
    +
    +    Regression coverage for #21658 / #5304: a single flaky
    +    ``check_terminal_requirements()`` (Docker daemon busy, probe timeout)
    +    must not silently strip the terminal/file toolset from a subagent. After
    +    a recent success, a transient False is treated as a flake; a failure with
    +    no recent success — or past the grace window — is honored.
    +    """
    +
    +    @pytest.fixture(autouse=True)
    +    def _reset(self):
    +        from tools.registry import invalidate_check_fn_cache
    +
    +        invalidate_check_fn_cache()
    +        yield
    +        invalidate_check_fn_cache()
    +
    +    def test_transient_failure_after_success_is_suppressed(self, monkeypatch):
    +        import tools.registry as reg
    +
    +        calls = {"n": 0}
    +
    +        def flaky():
    +            calls["n"] += 1
    +            # First call succeeds, second flakes (False).
    +            return calls["n"] == 1
    +
    +        # Pin the cache clock so the TTL doesn't serve a stale entry between
    +        # the two probes — we want both to actually run.
    +        t = {"now": 1000.0}
    +        monkeypatch.setattr(reg.time, "monotonic", lambda: t["now"])
    +
    +        assert reg._check_fn_cached(flaky) is True  # records last-good
    +        t["now"] += reg._CHECK_FN_TTL_SECONDS + 1  # expire the TTL cache
    +        # Within grace window of the success → flake suppressed, stays True.
    +        assert reg._check_fn_cached(flaky) is True
    +        assert calls["n"] == 2  # the probe actually ran (not just cached)
    +
    +    def test_persistent_failure_after_grace_is_honored(self, monkeypatch):
    +        import tools.registry as reg
    +
    +        def good():
    +            return True
    +
    +        def bad():
    +            return False
    +
    +        t = {"now": 1000.0}
    +        monkeypatch.setattr(reg.time, "monotonic", lambda: t["now"])
    +
    +        assert reg._check_fn_cached(good) is True
    +        # Advance past the failure grace window, then fail.
    +        t["now"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1
    +        # Different fn so last-good for `good` doesn't apply; bad has no success.
    +        assert reg._check_fn_cached(bad) is False
    +
    +    def test_failure_with_no_prior_success_is_honored(self, monkeypatch):
    +        import tools.registry as reg
    +
    +        def never():
    +            return False
    +
    +        t = {"now": 1000.0}
    +        monkeypatch.setattr(reg.time, "monotonic", lambda: t["now"])
    +        assert reg._check_fn_cached(never) is False
    +
    +    def test_grace_expiry_lets_real_outage_through(self, monkeypatch):
    +        import tools.registry as reg
    +
    +        state = {"ok": True}
    +
    +        def probe():
    +            return state["ok"]
    +
    +        t = {"now": 1000.0}
    +        monkeypatch.setattr(reg.time, "monotonic", lambda: t["now"])
    +
    +        assert reg._check_fn_cached(probe) is True
    +        state["ok"] = False
    +        # Just past TTL, within grace → flake suppressed.
    +        t["now"] += reg._CHECK_FN_TTL_SECONDS + 1
    +        assert reg._check_fn_cached(probe) is True
    +        # Now move well past the grace window since the last success → honored.
    +        t["now"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1
    +        assert reg._check_fn_cached(probe) is False
    +
    +    def test_subagent_keeps_file_tools_through_docker_flake(self, monkeypatch):
    +        """End-to-end: a docker probe that flakes on the 2nd build keeps the
    +        file/terminal toolset available for the subagent being constructed."""
    +        import tools.registry as reg
    +
    +        flake = {"first": True}
    +
    +        def flaky_terminal_check():
    +            if flake["first"]:
    +                flake["first"] = False
    +                return True
    +            return False  # transient flake on the subagent build
    +
    +        monkeypatch.setattr(
    +            terminal_tool_module, "check_terminal_requirements", flaky_terminal_check
    +        )
    +        # file tools delegate to the same check via tools.check_file_requirements.
    +        import tools as tools_pkg
    +
    +        monkeypatch.setattr(
    +            tools_pkg, "check_file_requirements", flaky_terminal_check
    +        )
    +
    +        t = {"now": 5000.0}
    +        monkeypatch.setattr(reg.time, "monotonic", lambda: t["now"])
    +
    +        from model_tools import get_tool_definitions, _clear_tool_defs_cache
    +
    +        reg.invalidate_check_fn_cache()
    +        _clear_tool_defs_cache()
    +        # Parent build (probe ok) → records last-good.
    +        parent = get_tool_definitions(enabled_toolsets=["terminal", "file"], quiet_mode=True)
    +        assert "read_file" in {x["function"]["name"] for x in parent}
    +
    +        # Subagent build moments later: TTL expired, probe flakes False, but
    +        # within grace → file/terminal tools must still resolve.
    +        t["now"] += reg._CHECK_FN_TTL_SECONDS + 1
    +        _clear_tool_defs_cache()
    +        child = get_tool_definitions(enabled_toolsets=["terminal", "file"], quiet_mode=True)
    +        child_names = {x["function"]["name"] for x in child}
    +        assert {"read_file", "write_file", "patch", "search_files", "terminal"}.issubset(
    +            child_names
    +        )
    ```
  - `tools/registry.py`

    ```diff
    @@ -116,15 +116,40 @@ def __init__(self, name, toolset, schema, handler, check_fn,
     # timescales. Cache results for ~30 s so env-var flips via ``hermes tools``
     # or live credential file changes propagate within a turn or two without
     # requiring any explicit invalidation.
    +#
    +# Transient-failure suppression (issue #21658 / #5304): these probes can flap.
    +# A single ``subprocess.run([docker, "version"], timeout=5)`` that times out
    +# under load returns False for one call, which would silently strip the entire
    +# terminal+file toolset from whatever agent is being built at that instant —
    +# most visibly a delegate_task subagent, which then reports "Tool read_file
    +# does not exist". To absorb such flakes WITHOUT pinning a permanently-stale
    +# "available" verdict, we remember the last time each check returned True and,
    +# when a fresh probe fails within a short grace window of that last success,
    +# we serve the last-good True instead of caching the failure. A failure that
    +# persists past the grace window is honored normally, so a backend that really
    +# went down stops advertising its tools.
     # ---------------------------------------------------------------------------
     
     _CHECK_FN_TTL_SECONDS = 30.0
    +# How long after a successful check a subsequent transient failure is treated
    +# as a flake (last-good True is served) rather than a real outage. Kept short
    +# so a genuinely-down backend is reflected within a couple of turns.
    +_CHECK_FN_FAILURE_GRACE_SECONDS = 60.0
     _check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
    +# Monotonic timestamp of the most recent True result per check_fn.
    +_check_fn_last_good: Dict[Callable, float] = {}
     _check_fn_cache_lock = threading.Lock()
     
     
     def _check_fn_cached(fn: Callable) -> bool:
    -    """Return bool(fn()), TTL-cached across calls. Swallows exceptions as False."""
    +    """Return bool(fn()), TTL-cached across calls.
    +
    +    Exceptions are swallowed as False. A transient False/exception within
    +    ``_CHECK_FN_FAILURE_GRACE_SECONDS`` of the last True is suppressed (the
    +    last-good True is returned and the failure is NOT cached, so the next call
    +    re-probes) to keep flaky external checks (Docker daemon busy, socket
    +    contention, probe timeout) from silently stripping tools mid-session.
    +    """
         now = time.monotonic()
         with _check_fn_cache_lock:
             cached = _check_fn_cache.get(fn)
    ```

    ```diff
    @@ -132,13 +157,43 @@ def _check_fn_cached(fn: Callable) -> bool:
                 ts, value = cached
                 if now - ts < _CHECK_FN_TTL_SECONDS:
                     return value
    +
    +    raised = False
         try:
             value = bool(fn())
         except Exception:
             value = False
    +        raised = True
    +
         with _check_fn_cache_lock:
    -        _check_fn_cache[fn] = (now, value)
    -    return value
    +        if value:
    +            _check_fn_last_good[fn] = now
    +            _check_fn_cache[fn] = (now, True)
    +            return True
    +
    +        last_good = _check_fn_last_good.get(fn)
    +        if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS:
    +            # Recent success → treat this failure as a flake. Serve last-good
    +            # True and do NOT cache the failure, so the next call re-probes
    +            # rather than pinning a stale verdict for the full TTL.
    +            logger.warning(
    +                "check_fn %s failed (%s) within %.0fs of last success; "
    +                "treating as transient and keeping tool(s) available",
    +                getattr(fn, "__qualname__", fn),
    +                "raised" if raised else "returned False",
    +                _CHECK_FN_FAILURE_GRACE_SECONDS,
    +            )
    +            return True
    +
    +        # No recent success (or grace expired) — honor the failure. Log it so
    +        # silent tool loss in quiet mode (subagents) is diagnosable.
    +        logger.warning(
    +            "check_fn %s %s; dependent tools will be unavailable this turn",
    +            getattr(fn, "__qualname__", fn),
    +            "raised" if raised else "returned False",
    +        )
    +        _check_fn_cache[fn] = (now, False)
    +        return False
     
     
     def invalidate_check_fn_cache() -> None:
    ```

    ```diff
    @@ -146,6 +201,7 @@ def invalidate_check_fn_cache() -> None:
         affect tool availability (e.g. ``hermes tools enable``)."""
         with _check_fn_cache_lock:
             _check_fn_cache.clear()
    +        _check_fn_last_good.clear()
     
     
     class ToolRegistry:
    ```
- **#64223: fix(delegate): detect ACP abort and unrelated summaries as failed**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#68515`, `#68515`
- **#72403: feat(delegate): expose redacted child tool history in subagent_stop (salvage #62011)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/shell_hooks.py`, `docs/observability/README.md`, `hermes_cli/hooks.py`, `tests/agent/test_subagent_stop_hook.py`, `tests/tools/test_delegate.py`, `tools/delegate_tool.py`, `website/docs/user-guide/features/hooks.md`
- **#16789: feat(delegate): add side-effect manifest verification**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#16357`, `#16325`
- **#62011: feat(delegate): expose redacted child tool history**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#72403`, `#61974`
- **#63897: fix(delegate): add capability precheck to prevent fabricated success when goal requires unavailable tools**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#63899`, `#63897`, `#63899`, `#63899`, `#63899`
  - files: `tools/delegate_tool.py`
- **#63899: fix(delegate): surface child's resolved toolsets in async completion block (#63887)**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `154873a1b7a5`, `#63887`, `#63897`, `#63899`, `#63897`, `#63897`, `#63899`, `#63899`, `#63899`, `#63897`, `#63899`, `#63897`, `#63899`, `#63897`, `#63899`, `#63899`, `#63897`, `#63899`, `#63897`, `#63899`, `#374151`, `#63887`, `#63897`, `#63899`
  - files: `tests/tools/test_process_registry.py`, `tools/delegate_tool.py`, `tools/delegate_tool_child_run.py`, `tools/process_registry_notifications.py`

## Fix status

- Merged to main in PR #53904 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-27`.
- Subsystem: `acp`
