{"repo":"NousResearch/hermes-agent","public_id":"16357","display_label":"Delegate task subagent reliability and verification","summary":"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.","subsystem":"acp","counts":{"members":14,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":3,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53904},"resolution":{"state":"partially_resolved","by":"pr-53904,pr-72403"},"freshness":{"state":"verified","last_verified_at":"2026-07-27T03:36:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11164835164835164},"fix_edges":[{"issue_number":5304,"pr_number":53904,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":21658,"pr_number":53904,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61974,"pr_number":72403,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":16357,"title":"delegate_task: subagent side-effect verification — structural mechanism beyond the tool-description warning","url":null,"state":"open"},{"kind":"issue","number":19144,"title":"[Feature] Subagent output verification layer","url":null,"state":"closed"},{"kind":"issue","number":20807,"title":"delegate_task may return completed with unrelated summary and no verifiable side effects when using ACP child agents","url":null,"state":"open"},{"kind":"issue","number":21658,"title":"Subagent tool delegation inconsistent — file/terminal tools missing for children despite toolsets parameter","url":null,"state":"closed"},{"kind":"issue","number":61974,"title":"Feature: expose tool_call_history in subagent_stop hook payload","url":null,"state":"closed"},{"kind":"issue","number":63887,"title":"Async delegation: child reports 'completed' with fabricated success when the goal requires toolsets it cannot have","url":null,"state":"open"},{"kind":"pr","number":16789,"title":"feat(delegate): add side-effect manifest verification","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16357","number":16357},{"kind":"pr","value":"#16325","number":16325}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":20861,"title":"fix(delegate): detect ACP abort and unrelated summaries as failed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64223","number":64223}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":53904,"title":"fix(tools): suppress transient check_fn flakes so subagents keep file/terminal tools","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_terminal_tool_requirements.py","tools/registry.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"konsisumer\",  # PR #19608 salvage (read-modify-write merge in write_credential_pool to preserve concurrently-added credentials; #19566)\n     \"[redacted:email]\": \"linyubin\",  # PR #50228 salvage (eager fallback on persistent transport timeout/overloaded; #22277)\n+    \"[redacted:email]\": \"djstunami\",  # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304)\n     \"[redacted:email]\": \"dalenguyen\",  # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473)\n     \"[redacted:email]\": \"Blaryxoff\",  # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)\n     \"[redacted:email]\": \"DiamondEyesFox\",  # PR #53351 salvage (rebaseline in-place compression flushes to prevent duplicate compacted rows; #9096)"]},{"file":"tests/tools/test_terminal_tool_requirements.py","hunks":["@@ -64,3 +64,135 @@ def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeyp\n \n         assert \"terminal\" in names\n         assert \"execute_code\" in names\n+\n+\n+class TestCheckFnTransientFailureSuppression:\n+    \"\"\"The check_fn TTL cache should absorb transient probe failures.\n+\n+    Regression coverage for #21658 / #5304: a single flaky\n+    ``check_terminal_requirements()`` (Docker daemon busy, probe timeout)\n+    must not silently strip the terminal/file toolset from a subagent. After\n+    a recent success, a transient False is treated as a flake; a failure with\n+    no recent success — or past the grace window — is honored.\n+    \"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _reset(self):\n+        from tools.registry import invalidate_check_fn_cache\n+\n+        invalidate_check_fn_cache()\n+        yield\n+        invalidate_check_fn_cache()\n+\n+    def test_transient_failure_after_success_is_suppressed(self, monkeypatch):\n+        import tools.registry as reg\n+\n+        calls = {\"n\": 0}\n+\n+        def flaky():\n+            calls[\"n\"] += 1\n+            # First call succeeds, second flakes (False).\n+            return calls[\"n\"] == 1\n+\n+        # Pin the cache clock so the TTL doesn't serve a stale entry between\n+        # the two probes — we want both to actually run.\n+        t = {\"now\": 1000.0}\n+        monkeypatch.setattr(reg.time, \"monotonic\", lambda: t[\"now\"])\n+\n+        assert reg._check_fn_cached(flaky) is True  # records last-good\n+        t[\"now\"] += reg._CHECK_FN_TTL_SECONDS + 1  # expire the TTL cache\n+        # Within grace window of the success → flake suppressed, stays True.\n+        assert reg._check_fn_cached(flaky) is True\n+        assert calls[\"n\"] == 2  # the probe actually ran (not just cached)\n+\n+    def test_persistent_failure_after_grace_is_honored(self, monkeypatch):\n+        import tools.registry as reg\n+\n+        def good():\n+            return True\n+\n+        def bad():\n+            return False\n+\n+        t = {\"now\": 1000.0}\n+        monkeypatch.setattr(reg.time, \"monotonic\", lambda: t[\"now\"])\n+\n+        assert reg._check_fn_cached(good) is True\n+        # Advance past the failure grace window, then fail.\n+        t[\"now\"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1\n+        # Different fn so last-good for `good` doesn't apply; bad has no success.\n+        assert reg._check_fn_cached(bad) is False\n+\n+    def test_failure_with_no_prior_success_is_honored(self, monkeypatch):\n+        import tools.registry as reg\n+\n+        def never():\n+            return False\n+\n+        t = {\"now\": 1000.0}\n+        monkeypatch.setattr(reg.time, \"monotonic\", lambda: t[\"now\"])\n+        assert reg._check_fn_cached(never) is False\n+\n+    def test_grace_expiry_lets_real_outage_through(self, monkeypatch):\n+        import tools.registry as reg\n+\n+        state = {\"ok\": True}\n+\n+        def probe():\n+            return state[\"ok\"]\n+\n+        t = {\"now\": 1000.0}\n+        monkeypatch.setattr(reg.time, \"monotonic\", lambda: t[\"now\"])\n+\n+        assert reg._check_fn_cached(probe) is True\n+        state[\"ok\"] = False\n+        # Just past TTL, within grace → flake suppressed.\n+        t[\"now\"] += reg._CHECK_FN_TTL_SECONDS + 1\n+        assert reg._check_fn_cached(probe) is True\n+        # Now move well past the grace window since the last success → honored.\n+        t[\"now\"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1\n+        assert reg._check_fn_cached(probe) is False\n+\n+    def test_subagent_keeps_file_tools_through_docker_flake(self, monkeypatch):\n+        \"\"\"End-to-end: a docker probe that flakes on the 2nd build keeps the\n+        file/terminal toolset available for the subagent being constructed.\"\"\"\n+        import tools.registry as reg\n+\n+        flake = {\"first\": True}\n+\n+        def flaky_terminal_check():\n+            if flake[\"first\"]:\n+                flake[\"first\"] = False\n+                return True\n+            return False  # transient flake on the subagent build\n+\n+        monkeypatch.setattr(\n+            terminal_tool_module, \"check_terminal_requirements\", flaky_terminal_check\n+        )\n+        # file tools delegate to the same check via tools.check_file_requirements.\n+        import tools as tools_pkg\n+\n+        monkeypatch.setattr(\n+            tools_pkg, \"check_file_requirements\", flaky_terminal_check\n+        )\n+\n+        t = {\"now\": 5000.0}\n+        monkeypatch.setattr(reg.time, \"monotonic\", lambda: t[\"now\"])\n+\n+        from model_tools import get_tool_definitions, _clear_tool_defs_cache\n+\n+        reg.invalidate_check_fn_cache()\n+        _clear_tool_defs_cache()\n+        # Parent build (probe ok) → records last-good.\n+        parent = get_tool_definitions(enabled_toolsets=[\"terminal\", \"file\"], quiet_mode=True)\n+        assert \"read_file\" in {x[\"function\"][\"name\"] for x in parent}\n+\n+        # Subagent build moments later: TTL expired, probe flakes False, but\n+        # within grace → file/terminal tools must still resolve.\n+        t[\"now\"] += reg._CHECK_FN_TTL_SECONDS + 1\n+        _clear_tool_defs_cache()\n+        child = get_tool_definitions(enabled_toolsets=[\"terminal\", \"file\"], quiet_mode=True)\n+        child_names = {x[\"function\"][\"name\"] for x in child}\n+        assert {\"read_file\", \"write_file\", \"patch\", \"search_files\", \"terminal\"}.issubset(\n+            child_names\n+        )"]},{"file":"tools/registry.py","hunks":["@@ -116,15 +116,40 @@ def __init__(self, name, toolset, schema, handler, check_fn,\n # timescales. Cache results for ~30 s so env-var flips via ``hermes tools``\n # or live credential file changes propagate within a turn or two without\n # requiring any explicit invalidation.\n+#\n+# Transient-failure suppression (issue #21658 / #5304): these probes can flap.\n+# A single ``subprocess.run([docker, \"version\"], timeout=5)`` that times out\n+# under load returns False for one call, which would silently strip the entire\n+# terminal+file toolset from whatever agent is being built at that instant —\n+# most visibly a delegate_task subagent, which then reports \"Tool read_file\n+# does not exist\". To absorb such flakes WITHOUT pinning a permanently-stale\n+# \"available\" verdict, we remember the last time each check returned True and,\n+# when a fresh probe fails within a short grace window of that last success,\n+# we serve the last-good True instead of caching the failure. A failure that\n+# persists past the grace window is honored normally, so a backend that really\n+# went down stops advertising its tools.\n # ---------------------------------------------------------------------------\n \n _CHECK_FN_TTL_SECONDS = 30.0\n+# How long after a successful check a subsequent transient failure is treated\n+# as a flake (last-good True is served) rather than a real outage. Kept short\n+# so a genuinely-down backend is reflected within a couple of turns.\n+_CHECK_FN_FAILURE_GRACE_SECONDS = 60.0\n _check_fn_cache: Dict[Callable, tuple[float, bool]] = {}\n+# Monotonic timestamp of the most recent True result per check_fn.\n+_check_fn_last_good: Dict[Callable, float] = {}\n _check_fn_cache_lock = threading.Lock()\n \n \n def _check_fn_cached(fn: Callable) -> bool:\n-    \"\"\"Return bool(fn()), TTL-cached across calls. Swallows exceptions as False.\"\"\"\n+    \"\"\"Return bool(fn()), TTL-cached across calls.\n+\n+    Exceptions are swallowed as False. A transient False/exception within\n+    ``_CHECK_FN_FAILURE_GRACE_SECONDS`` of the last True is suppressed (the\n+    last-good True is returned and the failure is NOT cached, so the next call\n+    re-probes) to keep flaky external checks (Docker daemon busy, socket\n+    contention, probe timeout) from silently stripping tools mid-session.\n+    \"\"\"\n     now = time.monotonic()\n     with _check_fn_cache_lock:\n         cached = _check_fn_cache.get(fn)","@@ -132,13 +157,43 @@ def _check_fn_cached(fn: Callable) -> bool:\n             ts, value = cached\n             if now - ts < _CHECK_FN_TTL_SECONDS:\n                 return value\n+\n+    raised = False\n     try:\n         value = bool(fn())\n     except Exception:\n         value = False\n+        raised = True\n+\n     with _check_fn_cache_lock:\n-        _check_fn_cache[fn] = (now, value)\n-    return value\n+        if value:\n+            _check_fn_last_good[fn] = now\n+            _check_fn_cache[fn] = (now, True)\n+            return True\n+\n+        last_good = _check_fn_last_good.get(fn)\n+        if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS:\n+            # Recent success → treat this failure as a flake. Serve last-good\n+            # True and do NOT cache the failure, so the next call re-probes\n+            # rather than pinning a stale verdict for the full TTL.\n+            logger.warning(\n+                \"check_fn %s failed (%s) within %.0fs of last success; \"\n+                \"treating as transient and keeping tool(s) available\",\n+                getattr(fn, \"__qualname__\", fn),\n+                \"raised\" if raised else \"returned False\",\n+                _CHECK_FN_FAILURE_GRACE_SECONDS,\n+            )\n+            return True\n+\n+        # No recent success (or grace expired) — honor the failure. Log it so\n+        # silent tool loss in quiet mode (subagents) is diagnosable.\n+        logger.warning(\n+            \"check_fn %s %s; dependent tools will be unavailable this turn\",\n+            getattr(fn, \"__qualname__\", fn),\n+            \"raised\" if raised else \"returned False\",\n+        )\n+        _check_fn_cache[fn] = (now, False)\n+        return False\n \n \n def invalidate_check_fn_cache() -> None:","@@ -146,6 +201,7 @@ def invalidate_check_fn_cache() -> None:\n     affect tool availability (e.g. ``hermes tools enable``).\"\"\"\n     with _check_fn_cache_lock:\n         _check_fn_cache.clear()\n+        _check_fn_last_good.clear()\n \n \n class ToolRegistry:\n"]}]},{"kind":"pr","number":62011,"title":"feat(delegate): expose redacted child tool history","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#72403","number":72403},{"kind":"pr","value":"#61974","number":61974}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":63897,"title":"fix(delegate): add capability precheck to prevent fabricated success when goal requires unavailable tools","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63899","number":63899}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":63899,"title":"fix(delegate): surface child's resolved toolsets in async completion block (#63887)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"154873a1b7a5","number":0},{"kind":"pr","value":"#63887","number":63887},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#63887","number":63887},{"kind":"pr","value":"#63897","number":63897},{"kind":"pr","value":"#63899","number":63899}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":64223,"title":"fix(delegate): detect ACP abort and unrelated summaries as failed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68515","number":68515},{"kind":"pr","value":"#68515","number":68515}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":72403,"title":"feat(delegate): expose redacted child tool history in subagent_stop (salvage #62011)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}