{"repo":"NousResearch/hermes-agent","public_id":"46944","display_label":"Background flag not forwarded in task delegation","summary":"Multiple issues and fixes center on the background=true parameter being dropped or ignored when delegating tasks through the agent dispatch helper. Related work also proposes parallel subagent fan-out as a feature in the same delegation path.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49734},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-20T18:27:12+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":46944,"pr_number":49734,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":46944,"title":"delegate_task(background=true) is dropped by AIAgent dispatch helper","url":null,"state":"closed"},{"kind":"issue","number":46960,"title":"Bug: delegate_task(background=true) parameter not reaching model — synchronous fallback","url":null,"state":"closed"},{"kind":"pr","number":46945,"title":"fix(delegation): forward background flag in agent dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ea8a8b4af8612b655a5bbfc74eba21e1e806758d","number":0},{"kind":"pr","value":"#49734","number":49734}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49734,"title":"feat(delegation): background fan-out — parallel subagents, one consolidated return","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/tools/test_async_delegation.py","tools/async_delegation.py","tools/delegate_tool.py","tools/process_registry.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -5197,6 +5197,18 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:\n         invocation paths (concurrent, sequential, inline).\n         \"\"\"\n         from tools.delegate_tool import delegate_task as _delegate_task\n+        # Delegations from the top-level MODEL always run in the background —\n+        # the model does not get to choose. delegate_task returns immediately\n+        # with a handle (one per task) and each subagent's result re-enters the\n+        # conversation as a new message when it finishes. This applies to BOTH\n+        # a single task and a fan-out batch (each task becomes its own\n+        # independent background subagent). The one exception:\n+        #   - A delegation from an ORCHESTRATOR SUBAGENT (depth > 0) stays\n+        #     synchronous: the orchestrator needs its workers' results within\n+        #     its own turn to compose a summary, and a subagent doesn't own the\n+        #     gateway session the async result would route back to.\n+        # The schema-level `background` param is intentionally ignored here.\n+        _is_subagent = getattr(self, \"_delegate_depth\", 0) > 0\n         return _delegate_task(\n             goal=function_args.get(\"goal\"),\n             context=function_args.get(\"context\"),","@@ -5206,7 +5218,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:\n             acp_command=function_args.get(\"acp_command\"),\n             acp_args=function_args.get(\"acp_args\"),\n             role=function_args.get(\"role\"),\n-            background=function_args.get(\"background\"),\n+            background=(not _is_subagent),\n             parent_agent=self,\n         )\n "]},{"file":"tests/tools/test_async_delegation.py","hunks":["@@ -227,7 +227,8 @@ def test_completed_records_pruned_to_cap():\n \n def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch):\n     \"\"\"delegate_task(background=True) returns a handle without running the\n-    child synchronously, and the child completes on the background thread.\"\"\"\n+    child synchronously, and the child completes on the background thread.\n+    A single task is dispatched as a one-item background batch unit.\"\"\"\n     from unittest.mock import MagicMock, patch\n     import tools.delegate_tool as dt\n ","@@ -235,6 +236,8 @@ def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch):\n     parent._delegate_depth = 0\n     parent.session_id = \"sess\"\n     parent._interrupt_requested = False\n+    parent._active_children = []\n+    parent._active_children_lock = None\n     fake_child = MagicMock()\n     fake_child._delegate_role = \"leaf\"\n     fake_child._subagent_id = \"s1\"","@@ -253,55 +256,170 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):\n         \"model\": \"m\", \"provider\": None, \"base_url\": None, \"api_key\": None,\n         \"api_mode\": None, \"command\": None, \"args\": None,\n     }\n-    with patch.object(dt, \"_build_child_agent\", return_value=fake_child), \\\n-         patch.object(dt, \"_run_single_child\", side_effect=slow_child), \\\n-         patch.object(dt, \"_resolve_delegation_credentials\", return_value=creds):\n-        out = dt.delegate_task(\n-            goal=\"the real task\", context=\"ctx\", toolsets=[\"web\"],\n-            background=True, parent_agent=parent,\n-        )\n+    # monkeypatch (not `with`) so patches outlive delegate_task's return and\n+    # remain active while the background worker runs.\n+    monkeypatch.setattr(dt, \"_build_child_agent\", lambda **kw: fake_child)\n+    monkeypatch.setattr(dt, \"_run_single_child\", slow_child)\n+    monkeypatch.setattr(dt, \"_resolve_delegation_credentials\", lambda *a, **k: creds)\n+    out = dt.delegate_task(\n+        goal=\"the real task\", context=\"ctx\", toolsets=[\"web\"],\n+        background=True, parent_agent=parent,\n+    )\n \n     import json\n     parsed = json.loads(out)\n     assert parsed[\"status\"] == \"dispatched\"\n     assert parsed[\"mode\"] == \"background\"\n     assert parsed[\"delegation_id\"].startswith(\"deleg_\")\n-    # The real non-blocking invariant (environment-independent — no wall-clock\n-    # threshold that flakes on a loaded CI runner): delegate_task returned\n-    # while the child is STILL blocked on the closed gate, so no completion\n-    # event exists yet. A synchronous impl could not have returned here — it\n-    # would still be inside slow_child waiting on the gate.\n+    # Non-blocking invariant: delegate_task returned while the child is STILL\n+    # blocked on the closed gate, so no completion event exists yet.\n     assert process_registry.completion_queue.empty()\n-    assert ad.active_count() == 1  # child running in background, not finished\n+    assert ad.active_count() == 1  # one background batch unit, not finished\n \n     gate.set()\n     evt = _drain_one()\n     assert evt is not None\n     assert evt[\"type\"] == \"async_delegation\"\n-    assert evt[\"summary\"] == \"done: the real task\"\n+    # Single task rides the batch path → carries a 1-item results list.\n+    assert evt.get(\"is_batch\") is True\n+    assert len(evt[\"results\"]) == 1\n+    assert evt[\"results\"][0][\"summary\"] == \"done: the real task\"\n     text = format_process_notification(evt)\n     assert text is not None\n-    assert \"the real task\" in text and \"ctx\" in text\n+    assert \"the real task\" in text\n \n \n-def test_delegate_task_background_rejects_batch(monkeypatch):\n-    \"\"\"background=True with a multi-item tasks batch is rejected (v1: single-task only).\"\"\"\n+def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch):\n+    \"\"\"A multi-item batch with background=True dispatches the WHOLE fan-out as\n+    ONE background unit (one handle, one async slot). The children run in\n+    parallel and join; the consolidated results come back as a single\n+    completion event when ALL of them finish.\"\"\"\n     import json\n-    from unittest.mock import MagicMock\n+    from unittest.mock import MagicMock, patch\n     import tools.delegate_tool as dt\n \n     parent = MagicMock()\n     parent._delegate_depth = 0\n     parent.session_id = \"sess\"\n+    parent._interrupt_requested = False\n+    parent._active_children = []\n+    parent._active_children_lock = None\n+\n+    fake_child = MagicMock()\n+    fake_child._delegate_role = \"leaf\"\n+\n+    gate = threading.Event()\n+\n+    def _blocking_child(task_index, goal, child=None, parent_agent=None, **kw):\n+        gate.wait(timeout=5)\n+        return {\n+            \"task_index\": task_index, \"status\": \"completed\",\n+            \"summary\": f\"done: {goal}\", \"api_calls\": 1,\n+            \"duration_seconds\": 0.1, \"model\": \"m\", \"exit_reason\": \"completed\",\n+        }\n+\n+    creds = {\n+        \"model\": \"m\", \"provider\": None, \"base_url\": None, \"api_key\": None,\n+        \"api_mode\": None, \"command\": None, \"args\": None,\n+    }\n \n+    # Use monkeypatch (not a `with` block) so the patches stay active while the\n+    # background worker thread runs _execute_and_aggregate AFTER delegate_task\n+    # has already returned.\n+    monkeypatch.setattr(dt, \"_build_child_agent\", lambda **kw: fake_child)\n+    monkeypatch.setattr(dt, \"_run_single_child\", _blocking_child)\n+    monkeypatch.setattr(dt, \"_resolve_delegation_credentials\", lambda *a, **k: creds)\n     out = dt.delegate_task(\n-        tasks=[{\"goal\": \"a\"}, {\"goal\": \"b\"}],\n+        tasks=[{\"goal\": \"a\"}, {\"goal\": \"b\"}, {\"goal\": \"c\"}],\n         background=True,\n         parent_agent=parent,\n     )\n+\n     parsed = json.loads(out)\n-    assert \"error\" in parsed\n-    assert \"single-task only\" in parsed[\"error\"]\n+    assert parsed[\"status\"] == \"dispatched\"\n+    assert parsed[\"mode\"] == \"background\"\n+    assert parsed[\"count\"] == 3\n+    assert parsed[\"delegation_id\"].startswith(\"deleg_\")\n+    assert parsed[\"goals\"] == [\"a\", \"b\", \"c\"]\n+    # ONE background unit for the whole fan-out (not three), and the call\n+    # returned while all children are still blocked → chat not blocked.\n+    assert process_registry.completion_queue.empty()\n+    assert ad.active_count() == 1\n+\n+    # Release the children; the whole batch joins and emits ONE event.\n+    gate.set()\n+    evt = _drain_one()\n+    assert evt is not None\n+    assert evt[\"type\"] == \"async_delegation\"\n+    assert evt.get(\"is_batch\") is True\n+    assert len(evt[\"results\"]) == 3\n+    summaries = sorted(r[\"summary\"] for r in evt[\"results\"])\n+    assert summaries == [\"done: a\", \"done: b\", \"done: c\"]\n+    # The consolidated notification names all three tasks in one block.\n+    text = format_process_notification(evt)\n+    assert text is not None\n+    assert \"TASK 1/3\" in text and \"TASK 2/3\" in text and \"TASK 3/3\" in text\n+    assert \"done: a\" in text and \"done: b\" in text and \"done: c\" in text\n+    # No more events — it's a single combined completion, not N of them.\n+    assert _drain_one() is None\n+\n+\n+def test_model_dispatch_forces_background():\n+    \"\"\"The MODEL-facing dispatch path forces background=True for any top-level\n+    delegation (single task OR batch), and keeps it off for an orchestrator\n+    subagent (depth > 0). Direct delegate_task() callers are unaffected (they\n+    keep the synchronous default).\"\"\"\n+    import tools.delegate_tool as dt\n+    from unittest.mock import MagicMock\n+\n+    top = MagicMock()\n+    top._delegate_depth = 0\n+    sub = MagicMock()\n+    sub._delegate_depth = 1\n+\n+    # Registry-fallback helper: top-level always background, regardless of\n+    # single vs batch; subagent never.\n+    assert dt._model_background_value({\"goal\": \"x\"}, top) is True\n+    assert dt._model_background_value(\n+        {\"tasks\": [{\"goal\": \"a\"}, {\"goal\": \"b\"}]}, top\n+    ) is True\n+    assert dt._model_background_value({\"tasks\": [{\"goal\": \"a\"}]}, top) is True\n+    assert dt._model_background_value({\"goal\": \"x\"}, sub) is False\n+    assert dt._model_background_value(\n+        {\"tasks\": [{\"goal\": \"a\"}, {\"goal\": \"b\"}]}, sub\n+    ) is False\n+\n+\n+def test_run_agent_dispatch_forces_background():\n+    \"\"\"run_agent._dispatch_delegate_task — the live model path — forces\n+    background on for any top-level delegation (single OR batch) and off for a\n+    subagent.\"\"\"\n+    from unittest.mock import patch\n+    import run_agent\n+\n+    class _FakeAgent:\n+        _delegate_depth = 0\n+\n+    captured = {}\n+\n+    def _fake_delegate(**kwargs):\n+        captured.update(kwargs)\n+        return \"{}\"\n+\n+    with patch(\"tools.delegate_tool.delegate_task\", _fake_delegate):\n+        agent = _FakeAgent()\n+        run_agent.AIAgent._dispatch_delegate_task(agent, {\"goal\": \"x\"})\n+        assert captured[\"background\"] is True\n+\n+        run_agent.AIAgent._dispatch_delegate_task(\n+            agent, {\"tasks\": [{\"goal\": \"a\"}, {\"goal\": \"b\"}]}\n+        )\n+        assert captured[\"background\"] is True\n+\n+        sub = _FakeAgent()\n+        sub._delegate_depth = 1\n+        run_agent.AIAgent._dispatch_delegate_task(sub, {\"goal\": \"x\"})\n+        assert captured[\"background\"] is False\n \n \n def test_delegate_task_background_detaches_child_from_parent(monkeypatch):"]},{"file":"tools/async_delegation.py","hunks":["@@ -334,6 +334,176 @@ def _push_completion_event(\n         )\n \n \n+def dispatch_async_delegation_batch(\n+    *,\n+    goals: List[str],\n+    context: Optional[str],\n+    toolsets: Optional[List[str]],\n+    role: str,\n+    model: Optional[str],\n+    session_key: str,\n+    runner: Callable[[], Dict[str, Any]],\n+    interrupt_fn: Optional[Callable[[], None]] = None,\n+    max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,\n+) -> Dict[str, Any]:\n+    \"\"\"Dispatch a WHOLE fan-out batch as ONE background unit.\n+\n+    Unlike ``dispatch_async_delegation`` (which backs a single subagent),\n+    ``runner`` here runs the entire batch — it builds and joins on every child\n+    in parallel and returns the combined ``{\"results\": [...],\n+    \"total_duration_seconds\": N}`` dict that the synchronous path would have\n+    returned. We occupy ONE async slot for the whole batch (the in-batch\n+    parallelism is bounded separately by ``max_concurrent_children``), so a\n+    single ``delegate_task`` fan-out never exhausts the async pool by itself.\n+\n+    When the batch finishes, a SINGLE completion event is pushed onto the\n+    shared ``process_registry.completion_queue`` carrying the full per-task\n+    ``results`` list, so the consolidated summaries re-enter the conversation\n+    as one message once every child is done — the chat is never blocked while\n+    they run.\n+\n+    Returns ``{\"status\": \"dispatched\", \"delegation_id\": ...}`` on success or\n+    ``{\"status\": \"rejected\", \"error\": ...}`` when the async pool is at\n+    capacity.\n+    \"\"\"\n+    delegation_id = _new_delegation_id()\n+    dispatched_at = time.time()\n+    n = len(goals)\n+    # A combined goal label for status listings / the completion header.\n+    combined_goal = (\n+        goals[0] if n == 1 else f\"{n} parallel subagents: \" + \"; \".join(g[:40] for g in goals)\n+    )\n+    record: Dict[str, Any] = {\n+        \"delegation_id\": delegation_id,\n+        \"goal\": combined_goal,\n+        \"goals\": list(goals),\n+        \"context\": context,\n+        \"toolsets\": list(toolsets) if toolsets else None,\n+        \"role\": role,\n+        \"model\": model,\n+        \"session_key\": session_key,\n+        \"status\": \"running\",\n+        \"dispatched_at\": dispatched_at,\n+        \"completed_at\": None,\n+        \"interrupt_fn\": interrupt_fn,\n+        \"is_batch\": True,\n+    }\n+    with _records_lock:\n+        running = sum(\n+            1 for r in _records.values() if r.get(\"status\") == \"running\"\n+        )\n+        if running >= max_async_children:\n+            return {\n+                \"status\": \"rejected\",\n+                \"error\": (\n+                    f\"Async delegation capacity reached ({max_async_children} \"\n+                    f\"running). Wait for one to finish (its result will re-enter \"\n+                    f\"the chat), or raise delegation.max_async_children in \"\n+                    f\"config.yaml to allow more concurrent background units.\"\n+                ),\n+            }\n+        _records[delegation_id] = record\n+\n+    executor = _get_executor(max_async_children)\n+\n+    def _worker() -> None:\n+        combined: Dict[str, Any] = {}\n+        status = \"error\"\n+        try:\n+            combined = runner() or {}\n+            # Batch status: completed unless every child errored/was interrupted.\n+            child_results = combined.get(\"results\") or []\n+            if child_results and all(\n+                (r.get(\"status\") not in (\"completed\", \"success\"))\n+                for r in child_results\n+            ):\n+                status = \"error\"\n+            else:\n+                status = \"completed\"\n+        except Exception as exc:  # noqa: BLE001 — must never crash the worker\n+            logger.exception(\"Async delegation batch %s crashed\", delegation_id)\n+            combined = {\n+                \"results\": [],\n+                \"error\": f\"{type(exc).__name__}: {exc}\",\n+                \"total_duration_seconds\": round(time.time() - dispatched_at, 2),\n+            }\n+            status = \"error\"\n+        finally:\n+            _finalize_batch(delegation_id, combined, status)\n+\n+    try:\n+        executor.submit(_worker)\n+    except Exception as exc:  # pragma: no cover\n+        with _records_lock:\n+            _records.pop(delegation_id, None)\n+        return {\n+            \"status\": \"rejected\",\n+            \"error\": f\"Failed to schedule async delegation batch: {exc}\",\n+        }\n+\n+    logger.info(\n+        \"Dispatched async delegation batch %s (%d task(s), session_key=%s)\",\n+        delegation_id, n, session_key or \"<cli>\",\n+    )\n+    return {\"status\": \"dispatched\", \"delegation_id\": delegation_id}\n+\n+\n+def _finalize_batch(\n+    delegation_id: str, combined: Dict[str, Any], status: str\n+) -> None:\n+    \"\"\"Mark a batch record complete and push ONE combined completion event.\"\"\"\n+    with _records_lock:\n+        record = _records.get(delegation_id)\n+        if record is None:\n+            return\n+        record[\"status\"] = status\n+        record[\"completed_at\"] = time.time()\n+        record[\"interrupt_fn\"] = None\n+        event_record = dict(record)\n+        _prune_completed_locked()\n+\n+    try:\n+        from tools.process_registry import process_registry\n+    except Exception as exc:  # pragma: no cover\n+        logger.error(\n+            \"Async delegation batch %s finished but process_registry import \"\n+            \"failed; result lost: %s\",\n+            delegation_id, exc,\n+        )\n+        return\n+\n+    dispatched_at = event_record.get(\"dispatched_at\") or time.time()\n+    completed_at = event_record.get(\"completed_at\") or time.time()\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"delegation_id\": delegation_id,\n+        \"session_key\": event_record.get(\"session_key\", \"\"),\n+        \"goal\": event_record.get(\"goal\", \"\"),\n+        \"goals\": event_record.get(\"goals\"),\n+        \"context\": event_record.get(\"context\"),\n+        \"toolsets\": event_record.get(\"toolsets\"),\n+        \"role\": event_record.get(\"role\"),\n+        \"model\": event_record.get(\"model\"),\n+        \"status\": status,\n+        \"is_batch\": True,\n+        # The full per-task results list — the formatter renders a\n+        # consolidated multi-task block from this.\n+        \"results\": combined.get(\"results\") or [],\n+        \"error\": combined.get(\"error\"),\n+        \"total_duration_seconds\": combined.get(\"total_duration_seconds\"),\n+        \"dispatched_at\": dispatched_at,\n+        \"completed_at\": completed_at,\n+    }\n+    try:\n+        process_registry.completion_queue.put(evt)\n+    except Exception as exc:  # pragma: no cover\n+        logger.error(\n+            \"Async delegation batch %s: failed to enqueue completion event; \"\n+            \"result lost: %s\",\n+            delegation_id, exc,\n+        )\n+\n+\n def list_async_delegations() -> List[Dict[str, Any]]:\n     \"\"\"Snapshot of async delegations (running + recently completed).\n "]},{"file":"tools/delegate_tool.py","hunks":["@@ -2103,18 +2103,12 @@ def delegate_task(\n     # Normalise the top-level role once; per-task overrides re-normalise.\n     top_role = _normalize_role(role)\n \n-    # Async (background) delegation is single-task only in v1. A batch carries\n-    # fan-out semantics (N handles, partial completion) that double the state\n-    # model — reject early with a clear message rather than silently running\n-    # the batch synchronously.\n+    # Background (async) delegation now applies to BOTH single tasks and\n+    # batches. A batch simply becomes N independent async dispatches: each\n+    # child runs on the daemon executor and re-enters the conversation via\n+    # the completion queue on its own, carrying its own handle. There's no\n+    # combined \"wait for all\" — fan-out is exactly N background subagents.\n     background = is_truthy_value(background, default=False) if background is not None else False\n-    if background and tasks and isinstance(tasks, list) and len(tasks) > 1:\n-        return tool_error(\n-            \"background=true is single-task only. Dispatch one background \"\n-            \"subagent per delegate_task call (each returns its own handle and \"\n-            \"re-enters the conversation independently), or run the batch \"\n-            \"synchronously with background=false.\"\n-        )\n \n     # Depth limit — configurable via delegation.max_spawn_depth,\n     # default 2 for parity with the original MAX_DEPTH constant.","@@ -2250,150 +2244,101 @@ def delegate_task(\n         # Authoritative restore: reset global to parent's tool names after all children built\n         _model_tools._last_resolved_tool_names = _parent_tool_names\n \n-    if n_tasks == 1:\n-        # Single task -- run directly (no thread pool overhead)\n-        _i, _t, child = children[0]\n-\n-        # ----- Async / background dispatch -----\n-        # When background=true, hand the already-built child to the async\n-        # delegation registry and return a handle immediately. The child runs\n-        # on a daemon executor; its result re-enters the conversation as a\n-        # fresh turn via process_registry.completion_queue (see\n-        # tools/async_delegation.py). Batch async is intentionally NOT\n-        # supported in v1 — the rejection is handled before we get here.\n-        if background:\n-            from tools.async_delegation import dispatch_async_delegation\n-            from tools.approval import get_current_session_key\n-\n-            # Capture the gateway routing key on THIS (parent) thread — the\n-            # daemon worker won't carry the session contextvar.\n-            _session_key = get_current_session_key(default=\"\")\n-\n-            # Detach the child from the parent's interrupt-propagation list.\n-            # _build_child_agent registered it there (correct for sync\n-            # children, which block the parent's turn), but a BACKGROUND\n-            # child must survive parent-turn interrupts (Ctrl+C, mid-turn\n-            # steering), cache evicts (release_clients), and session close\n-            # (/new) — otherwise the detached subagent dies with whatever\n-            # the parent was doing when it was dispatched. Its lifecycle is\n-            # owned by the async-delegation registry (interrupt_fn below),\n-            # and _run_single_child's finally block closes its resources\n-            # when it finishes.\n-            if hasattr(parent_agent, \"_active_children\"):\n-                try:\n-                    _ac_lock = getattr(parent_agent, \"_active_children_lock\", None)\n-                    if _ac_lock:\n-                        with _ac_lock:\n-                            parent_agent._active_children.remove(child)\n-                    else:\n-                        parent_agent._active_children.remove(child)\n-                except ValueError:\n-                    pass\n-\n-            def _async_runner(_child=child, _goal=_t[\"goal\"]):\n-                return _run_single_child(0, _goal, _child, parent_agent)\n-\n-            def _async_interrupt(_child=child):\n-                try:\n-                    if hasattr(_child, \"interrupt\"):\n-                        _child.interrupt(\"Async delegation cancelled\")\n-                    elif hasattr(_child, \"_interrupt_requested\"):\n-                        _child._interrupt_requested = True\n-                except Exception:\n-                    pass\n-\n-            dispatch = dispatch_async_delegation(\n-                goal=_t[\"goal\"],\n-                context=_t.get(\"context\"),\n-                toolsets=_t.get(\"toolsets\") or toolsets,\n-                role=_normalize_role(_t.get(\"role\") or top_role),\n-                model=creds[\"model\"],\n-                session_key=_session_key,\n-                runner=_async_runner,\n-                interrupt_fn=_async_interrupt,\n-                max_async_children=_get_max_async_children(),\n-            )\n-\n-            if dispatch.get(\"status\") == \"dispatched\":\n-                return json.dumps(\n-                    {\n-                        \"status\": \"dispatched\",\n-                        \"delegation_id\": dispatch[\"delegation_id\"],\n-                        \"goal\": _t[\"goal\"],\n-                        \"mode\": \"background\",\n-                        \"note\": (\n-                            \"Subagent is running in the background. You and the \"\n-                            \"user can keep working; the full task source and \"\n-                            \"result will re-enter the conversation as a new \"\n-                            \"message when it finishes. Do not wait or poll — \"\n-                            \"just continue.\"\n-                        ),\n-                    },\n-                    ensure_ascii=False,\n-                )\n-            # Rejected (at capacity or schedule failure) — surface as a tool\n-            # error so the model can fall back to synchronous delegation.\n-            return tool_error(\n-                dispatch.get(\"error\", \"Async delegation could not be scheduled.\")\n-            )\n-\n-        result = _run_single_child(0, _t[\"goal\"], child, parent_agent)\n-        results.append(result)\n-    else:\n-        # Batch -- run in parallel with per-task progress lines\n-        completed_count = 0\n-        spinner_ref = getattr(parent_agent, \"_delegate_spinner\", None)\n-\n-        with ThreadPoolExecutor(max_workers=max_children) as executor:\n-            futures = {}\n-            for i, t, child in children:\n-                future = executor.submit(\n-                    _run_single_child,\n-                    task_index=i,\n-                    goal=t[\"goal\"],\n-                    child=child,\n-                    parent_agent=parent_agent,\n-                )\n-                futures[future] = i\n-\n-            # Poll futures with interrupt checking.  as_completed() blocks\n-            # until ALL futures finish — if a child agent gets stuck,\n-            # the parent blocks forever even after interrupt propagation.\n-            # Instead, use wait() with a short timeout so we can bail\n-            # when the parent is interrupted.\n-            # Map task_index -> child agent, so fabricated entries for\n-            # still-pending futures can carry the correct _delegate_role.\n-            _child_by_index = {i: child for (i, _, child) in children}\n-\n-            pending = set(futures.keys())\n-            while pending:\n-                if getattr(parent_agent, \"_interrupt_requested\", False) is True:\n-                    # Parent interrupted — collect whatever finished and\n-                    # abandon the rest.  Children already received the\n-                    # interrupt signal; we just can't wait forever.\n-                    for f in pending:\n-                        idx = futures[f]\n-                        if f.done():\n-                            try:\n-                                entry = f.result()\n-                            except Exception as exc:\n+    def _execute_and_aggregate() -> dict:\n+        \"\"\"Run all built children (1 or N), join on them, aggregate results,\n+        fire subagent_stop hooks + cost rollup, and return the combined result\n+        dict. Used by BOTH the synchronous path and the background runner. In\n+        the background case this whole function runs on the daemon executor, so\n+        the parent turn isn't blocked — but the batch still JOINS on itself\n+        here (all children must finish) before producing ONE consolidated\n+        results block. That is the contract: fan-out runs in the background,\n+        waits on each other, and returns together.\n+        \"\"\"\n+        if n_tasks == 1:\n+            # Single task -- run directly (no thread pool overhead)\n+            _i, _t, child = children[0]\n+            result = _run_single_child(_i, _t[\"goal\"], child, parent_agent)\n+            results.append(result)\n+        else:\n+            # Batch -- run in parallel with per-task progress lines\n+            completed_count = 0\n+            spinner_ref = getattr(parent_agent, \"_delegate_spinner\", None)\n+\n+            with ThreadPoolExecutor(max_workers=max_children) as executor:\n+                futures = {}\n+                for i, t, child in children:\n+                    future = executor.submit(\n+                        _run_single_child,\n+                        task_index=i,\n+                        goal=t[\"goal\"],\n+                        child=child,\n+                        parent_agent=parent_agent,\n+                    )\n+                    futures[future] = i\n+\n+                # Poll futures with interrupt checking.  as_completed() blocks\n+                # until ALL futures finish — if a child agent gets stuck,\n+                # the parent blocks forever even after interrupt propagation.\n+                # Instead, use wait() with a short timeout so we can bail\n+                # when the parent is interrupted.\n+                # Map task_index -> child agent, so fabricated entries for\n+                # still-pending futures can carry the correct _delegate_role.\n+                _child_by_index = {i: child for (i, _, child) in children}\n+\n+                pending = set(futures.keys())\n+                while pending:\n+                    if getattr(parent_agent, \"_interrupt_requested\", False) is True:\n+                        # Parent interrupted — collect whatever finished and\n+                        # abandon the rest.  Children already received the\n+                        # interrupt signal; we just can't wait forever.\n+                        for f in pending:\n+                            idx = futures[f]\n+                            if f.done():\n+                                try:\n+                                    entry = f.result()\n+                                except Exception as exc:\n+                                    entry = {\n+                                        \"task_index\": idx,\n+                                        \"status\": \"error\",\n+                                        \"summary\": None,\n+                                        \"error\": str(exc),\n+                                        \"api_calls\": 0,\n+                                        \"duration_seconds\": 0,\n+                                        \"_child_role\": getattr(\n+                                            _child_by_index.get(idx), \"_delegate_role\", None\n+                                        ),\n+                                    }\n+                            else:\n                                 entry = {\n                                     \"task_index\": idx,\n-                                    \"status\": \"error\",\n+                                    \"status\": \"interrupted\",\n                                     \"summary\": None,\n-                                    \"error\": str(exc),\n+                                    \"error\": \"Parent agent interrupted — child did not finish in time\",\n                                     \"api_calls\": 0,\n                                     \"duration_seconds\": 0,\n                                     \"_child_role\": getattr(\n                                         _child_by_index.get(idx), \"_delegate_role\", None\n                                     ),\n                                 }\n-                        else:\n+                            results.append(entry)\n+                            completed_count += 1\n+                        break\n+\n+                    from concurrent.futures import wait as _cf_wait, FIRST_COMPLETED\n+\n+                    done, pending = _cf_wait(\n+                        pending, timeout=0.5, return_when=FIRST_COMPLETED\n+                    )\n+                    for future in done:\n+                        try:\n+                            entry = future.result()\n+                        except Exception as exc:\n+                            idx = futures[future]\n                             entry = {\n                                 \"task_index\": idx,\n-                                \"status\": \"interrupted\",\n+                                \"status\": \"error\",\n                                 \"summary\": None,\n-                                \"error\": \"Parent agent interrupted — child did not finish in time\",\n+                                \"error\": str(exc),\n                                 \"api_calls\": 0,\n                                 \"duration_seconds\": 0,\n                                 \"_child_role\": getattr(","@@ -2402,165 +2347,229 @@ def _async_interrupt(_child=child):\n                             }\n                         results.append(entry)\n                         completed_count += 1\n-                    break\n-\n-                from concurrent.futures import wait as _cf_wait, FIRST_COMPLETED\n \n-                done, pending = _cf_wait(\n-                    pending, timeout=0.5, return_when=FIRST_COMPLETED\n-                )\n-                for future in done:\n-                    try:\n-                        entry = future.result()\n-                    except Exception as exc:\n-                        idx = futures[future]\n-                        entry = {\n-                            \"task_index\": idx,\n-                            \"status\": \"error\",\n-                            \"summary\": None,\n-                            \"error\": str(exc),\n-                            \"api_calls\": 0,\n-                            \"duration_seconds\": 0,\n-                            \"_child_role\": getattr(\n-                                _child_by_index.get(idx), \"_delegate_role\", None\n-                            ),\n-                        }\n-                    results.append(entry)\n-                    completed_count += 1\n-\n-                    # Print per-task completion line above the spinner\n-                    idx = entry[\"task_index\"]\n-                    label = (\n-                        task_labels[idx] if idx < len(task_labels) else f\"Task {idx}\"\n-                    )\n-                    dur = entry.get(\"duration_seconds\", 0)\n-                    status = entry.get(\"status\", \"?\")\n-                    icon = \"✓\" if status == \"completed\" else \"✗\"\n-                    remaining = n_tasks - completed_count\n-                    completion_line = f\"{icon} [{idx+1}/{n_tasks}] {label}  ({dur}s)\"\n-                    if spinner_ref:\n-                        try:\n-                            spinner_ref.print_above(completion_line)\n-                        except Exception:\n+                        # Print per-task completion line above the spinner\n+                        idx = entry[\"task_index\"]\n+                        label = (\n+                            task_labels[idx] if idx < len(task_labels) else f\"Task {idx}\"\n+                        )\n+                        dur = entry.get(\"duration_seconds\", 0)\n+                        status = entry.get(\"status\", \"?\")\n+                        icon = \"✓\" if status == \"completed\" else \"✗\"\n+                        remaining = n_tasks - completed_count\n+                        completion_line = f\"{icon} [{idx+1}/{n_tasks}] {label}  ({dur}s)\"\n+                        if spinner_ref:\n+                            try:\n+                                spinner_ref.print_above(completion_line)\n+                            except Exception:\n+                                print(f\"  {completion_line}\")\n+                        else:\n                             print(f\"  {completion_line}\")\n-                    else:\n-                        print(f\"  {completion_line}\")\n \n-                    # Update spinner text to show remaining count\n-                    if spinner_ref and remaining > 0:\n-                        try:\n-                            spinner_ref.update_text(\n-                                f\"🔀 {remaining} task{'s' if remaining != 1 else ''} remaining\"\n-                            )\n-                        except Exception as e:\n-                            logger.debug(\"Spinner update_text failed: %s\", e)\n+                        # Update spinner text to show remaining count\n+                        if spinner_ref and remaining > 0:\n+                            try:\n+                                spinner_ref.update_text(\n+                                    f\"🔀 {remaining} task{'s' if remaining != 1 else ''} remaining\"\n+                                )\n+                            except Exception as e:\n+                                logger.debug(\"Spinner update_text failed: %s\", e)\n \n-        # Sort by task_index so results match input order\n-        results.sort(key=lambda r: r[\"task_index\"])\n+            # Sort by task_index so results match input order\n+            results.sort(key=lambda r: r[\"task_index\"])\n \n-    # Notify parent's memory provider of delegation outcomes\n-    if (\n-        parent_agent\n-        and hasattr(parent_agent, \"_memory_manager\")\n-        and parent_agent._memory_manager\n-    ):\n+        # Notify parent's memory provider of delegation outcomes\n+        if (\n+            parent_agent\n+            and hasattr(parent_agent, \"_memory_manager\")\n+            and parent_agent._memory_manager\n+        ):\n+            for entry in results:\n+                try:\n+                    _task_goal = (\n+                        task_list[entry[\"task_index\"]][\"goal\"]\n+                        if entry[\"task_index\"] < len(task_list)\n+                        else \"\"\n+                    )\n+                    parent_agent._memory_manager.on_delegation(\n+                        task=_task_goal,\n+                        result=entry.get(\"summary\", \"\") or \"\",\n+                        child_session_id=(\n+                            getattr(children[entry[\"task_index\"]][2], \"session_id\", \"\")\n+                            if entry[\"task_index\"] < len(children)\n+                            else \"\"\n+                        ),\n+                    )\n+                except Exception:\n+                    pass\n+\n+        # Fire subagent_stop hooks once per child, serialised on the parent thread.\n+        # This keeps Python-plugin and shell-hook callbacks off of the worker threads\n+        # that ran the children, so hook authors don't need to reason about\n+        # concurrent invocation.  Role was captured into the entry dict in\n+        # _run_single_child (or the fabricated-entry branches above) before the\n+        # child was closed.\n+        _parent_session_id = getattr(parent_agent, \"session_id\", None)\n+        try:\n+            from hermes_cli.plugins import invoke_hook as _invoke_hook\n+        except Exception:\n+            _invoke_hook = None\n+        # Aggregate child spend here so the parent's footer/UI reflect the true\n+        # cost of a subagent-heavy turn.  Port of Kilo-Org/kilocode#9448.  Each\n+        # child's cost was captured in _run_single_child before its AIAgent was\n+        # closed; we fold them into the parent in one pass alongside the\n+        # subagent_stop hook loop so we don't walk `results` twice.\n+        _children_cost_total = 0.0\n         for entry in results:\n+            child_role = entry.pop(\"_child_role\", None)\n+            child_cost = entry.pop(\"_child_cost_usd\", 0.0)\n             try:\n-                _task_goal = (\n-                    task_list[entry[\"task_index\"]][\"goal\"]\n-                    if entry[\"task_index\"] < len(task_list)\n-                    else \"\"\n+                if child_cost:\n+                    _children_cost_total += float(child_cost)\n+            except (TypeError, ValueError):\n+                pass\n+            if _invoke_hook is None:\n+                continue\n+            try:\n+                _child_index = entry.get(\"task_index\", -1)\n+                _child_agent = (\n+                    children[_child_index][2]\n+                    if isinstance(_child_index, int) and 0 <= _child_index < len(children)\n+                    else None\n                 )\n-                parent_agent._memory_manager.on_delegation(\n-                    task=_task_goal,\n-                    result=entry.get(\"summary\", \"\") or \"\",\n-                    child_session_id=(\n-                        getattr(children[entry[\"task_index\"]][2], \"session_id\", \"\")\n-                        if entry[\"task_index\"] < len(children)\n-                        else \"\"\n-                    ),\n+                _invoke_hook(\n+                    \"subagent_stop\",\n+                    parent_session_id=_parent_session_id,\n+                    parent_turn_id=getattr(parent_agent, \"_current_turn_id\", \"\") or \"\",\n+                    child_session_id=getattr(_child_agent, \"session_id\", None),\n+                    child_role=child_role,\n+                    child_summary=entry.get(\"summary\"),\n+                    child_status=entry.get(\"status\"),\n+                    duration_ms=int((entry.get(\"duration_seconds\") or 0) * 1000),\n                 )\n             except Exception:\n-                pass\n-\n-    # Fire subagent_stop hooks once per child, serialised on the parent thread.\n-    # This keeps Python-plugin and shell-hook callbacks off of the worker threads\n-    # that ran the children, so hook authors don't need to reason about\n-    # concurrent invocation.  Role was captured into the entry dict in\n-    # _run_single_child (or the fabricated-entry branches above) before the\n-    # child was closed.\n-    _parent_session_id = getattr(parent_agent, \"session_id\", None)\n-    try:\n-        from hermes_cli.plugins import invoke_hook as _invoke_hook\n-    except Exception:\n-        _invoke_hook = None\n-    # Aggregate child spend here so the parent's footer/UI reflect the true\n-    # cost of a subagent-heavy turn.  Port of Kilo-Org/kilocode#9448.  Each\n-    # child's cost was captured in _run_single_child before its AIAgent was\n-    # closed; we fold them into the parent in one pass alongside the\n-    # subagent_stop hook loop so we don't walk `results` twice.\n-    _children_cost_total = 0.0\n-    for entry in results:\n-        child_role = entry.pop(\"_child_role\", None)\n-        child_cost = entry.pop(\"_child_cost_usd\", 0.0)\n-        try:\n-            if child_cost:\n-                _children_cost_total += float(child_cost)\n-        except (TypeError, ValueError):\n-            pass\n-        if _invoke_hook is None:\n-            continue\n-        try:\n-            _child_index = entry.get(\"task_index\", -1)\n-            _child_agent = (\n-                children[_child_index][2]\n-                if isinstance(_child_index, int) and 0 <= _child_index < len(children)\n-                else None\n-            )\n-            _invoke_hook(\n-                \"subagent_stop\",\n-                parent_session_id=_parent_session_id,\n-                parent_turn_id=getattr(parent_agent, \"_current_turn_id\", \"\") or \"\",\n-                child_session_id=getattr(_child_agent, \"session_id\", None),\n-                child_role=child_role,\n-                child_summary=entry.get(\"summary\"),\n-                child_status=entry.get(\"status\"),\n-                duration_ms=int((entry.get(\"duration_seconds\") or 0) * 1000),\n-            )\n-        except Exception:\n-            logger.debug(\"subagent_stop hook invocation failed\", exc_info=True)\n-\n-    # Fold the aggregated child cost into the parent's session total.  This is\n-    # additive — each delegate_task call contributes its own children — so\n-    # nested orchestrator→worker trees roll up naturally: each layer's own\n-    # delegate_task() folds its direct children in, and when the orchestrator\n-    # itself finishes, its parent folds the orchestrator's now-inflated total\n-    # on top.  Degrades silently if the parent lacks the counter (older test\n-    # fixtures, etc.).\n-    if _children_cost_total > 0.0:\n-        try:\n-            current = float(getattr(parent_agent, \"session_estimated_cost_usd\", 0.0) or 0.0)\n-            parent_agent.session_estimated_cost_usd = current + _children_cost_total\n-            # Upgrade the cost_source so the UI doesn't label a partially-real\n-            # total as \"none\" when the parent itself hadn't billed any calls\n-            # yet (rare but possible when the parent's only action this turn\n-            # was delegate_task).\n-            if getattr(parent_agent, \"session_cost_source\", \"none\") in {None, \"\", \"none\"}:\n-                parent_agent.session_cost_source = \"subagent\"\n-            if getattr(parent_agent, \"session_cost_status\", \"unknown\") in {None, \"\", \"unknown\"}:\n-                parent_agent.session_cost_status = \"estimated\"\n-        except Exception:\n-            logger.debug(\"Subagent cost rollup failed\", exc_info=True)\n+                logger.debug(\"subagent_stop hook invocation failed\", exc_info=True)\n+\n+        # Fold the aggregated child cost into the parent's session total.  This is\n+        # additive — each delegate_task call contributes its own children — so\n+        # nested orchestrator→worker trees roll up naturally: each layer's own\n+        # delegate_task() folds its direct children in, and when the orchestrator\n+        # itself finishes, its parent folds the orchestrator's now-inflated total\n+        # on top.  Degrades silently if the parent lacks the counter (older test\n+        # fixtures, etc.).\n+        if _children_cost_total > 0.0:\n+            try:\n+                current = float(getattr(parent_agent, \"session_estimated_cost_usd\", 0.0) or 0.0)\n+                parent_agent.session_estimated_cost_usd = current + _children_cost_total\n+                # Upgrade the cost_source so the UI doesn't label a partially-real\n+                # total as \"none\" when the parent itself hadn't billed any calls\n+                # yet (rare but possible when the parent's only action this turn\n+                # was delegate_task).\n+                if getattr(parent_agent, \"session_cost_source\", \"none\") in {None, \"\", \"none\"}:\n+                    parent_agent.session_cost_source = \"subagent\"\n+                if getattr(parent_agent, \"session_cost_status\", \"unknown\") in {None, \"\", \"unknown\"}:\n+                    parent_agent.session_cost_status = \"estimated\"\n+            except Exception:\n+                logger.debug(\"Subagent cost rollup failed\", exc_info=True)\n \n-    total_duration = round(time.monotonic() - overall_start, 2)\n+        total_duration = round(time.monotonic() - overall_start, 2)\n \n-    return json.dumps(\n-        {\n+        return {\n             \"results\": results,\n             \"total_duration_seconds\": total_duration,\n-        },\n-        ensure_ascii=False,\n-    )\n+        }\n+\n+    # ----- Background dispatch: run the WHOLE batch as one async unit -----\n+    # When background is true, the entire fan-out runs on the daemon executor\n+    # via a single async delegation. _execute_and_aggregate() joins on every\n+    # child and produces ONE consolidated results block, which re-enters the\n+    # conversation as a single message when ALL children finish. The chat is\n+    # not blocked in the meantime. This is the contract: dispatch N subagents,\n+    # keep chatting, get the combined summaries back together at the end.\n+    if background:\n+        from tools.async_delegation import dispatch_async_delegation_batch\n+        from tools.approval import get_current_session_key\n+\n+        _session_key = get_current_session_key(default=\"\")\n+        _child_agents = [c for (_, _, c) in children]\n+\n+        # Detach every child from the parent's interrupt-propagation list — the\n+        # batch's lifecycle is owned by the async registry now, not the parent\n+        # turn. _build_child_agent attached them (correct for sync runs).\n+        if hasattr(parent_agent, \"_active_children\"):\n+            _ac_lock = getattr(parent_agent, \"_active_children_lock\", None)\n+            for _c in _child_agents:\n+                try:\n+                    if _ac_lock:\n+                        with _ac_lock:\n+                            parent_agent._active_children.remove(_c)\n+                    else:\n+                        parent_agent._active_children.remove(_c)\n+                except ValueError:\n+                    pass\n+\n+        def _batch_runner():\n+            return _execute_and_aggregate()\n+\n+        def _batch_interrupt():\n+            for _c in _child_agents:\n+                try:\n+                    if hasattr(_c, \"interrupt\"):\n+                        _c.interrupt(\"Async delegation cancelled\")\n+                    elif hasattr(_c, \"_interrupt_requested\"):\n+                        _c._interrupt_requested = True\n+                except Exception:\n+                    pass\n+\n+        _goals = [t[\"goal\"] for t in task_list]\n+        dispatch = dispatch_async_delegation_batch(\n+            goals=_goals,\n+            context=context,\n+            toolsets=toolsets,\n+            role=top_role,\n+            model=creds[\"model\"],\n+            session_key=_session_key,\n+            runner=_batch_runner,\n+            interrupt_fn=_batch_interrupt,\n+            max_async_children=_get_max_async_children(),\n+        )\n+\n+        if dispatch.get(\"status\") == \"dispatched\":\n+            n = len(_goals)\n+            note = (\n+                \"Subagent is running in the background. You and the user can \"\n+                \"keep working; its full result re-enters the conversation as a \"\n+                \"new message when it finishes. Do not wait or poll — just \"\n+                \"continue.\"\n+                if n == 1 else\n+                f\"{n} subagents are running in parallel in the background. You \"\n+                f\"and the user can keep working; they wait on each other and \"\n+                f\"their consolidated results re-enter the conversation as a \"\n+                f\"single message once ALL of them finish. Do not wait or poll \"\n+                f\"— just continue.\"\n+            )\n+            payload = {\n+                \"status\": \"dispatched\",\n+                \"mode\": \"background\",\n+                \"count\": n,\n+                \"delegation_id\": dispatch[\"delegation_id\"],\n+                \"goals\": _goals,\n+                \"note\": note,\n+            }\n+            return json.dumps(payload, ensure_ascii=False)\n+\n+        # Pool at capacity / schedule failure — children are still attached\n+        # (we detach above only on the parent list, but the async unit was\n+        # never accepted, so re-attaching isn't needed: we just run inline).\n+        logger.info(\n+            \"delegate_task: async pool at capacity (%s); running the whole \"\n+            \"batch synchronously instead.\",\n+            dispatch.get(\"error\", \"rejected\"),\n+        )\n+        return json.dumps(_execute_and_aggregate(), ensure_ascii=False)\n+\n+    # ----- Synchronous path -----\n+    return json.dumps(_execute_and_aggregate(), ensure_ascii=False)\n \n \n def _resolve_child_credential_pool(","@@ -2842,11 +2851,16 @@ def _build_top_level_description() -> str:\n         \"Only the final summary is returned -- intermediate tool results \"\n         \"never enter your context window.\\n\\n\"\n         \"TWO MODES (one of 'goal' or 'tasks' is required):\\n\"\n-        \"1. Single task: provide 'goal' (+ optional context, toolsets)\\n\"\n+        \"1. Single task: provide 'goal' (+ optional context, toolsets).\\n\"\n         f\"2. Batch (parallel): provide 'tasks' array with up to {max_children} \"\n         f\"items concurrently for this user (configured via \"\n-        f\"delegation.max_concurrent_children in config.yaml). \"\n-        f\"All run in parallel and results are returned together. {nesting_clause}\\n\\n\"\n+        f\"delegation.max_concurrent_children in config.yaml). {nesting_clause}\\n\\n\"\n+        \"BOTH MODES RUN IN THE BACKGROUND. delegate_task returns immediately — \"\n+        \"you and the user keep working, and each subagent's full result \"\n+        \"re-enters the conversation as its own new message when it finishes. A \"\n+        \"batch is just N independent background subagents (N handles, each \"\n+        \"completes on its own). Do NOT wait or poll; just continue with other \"\n+        \"work after dispatching.\\n\\n\"\n         \"WHEN TO USE delegate_task:\\n\"\n         \"- Reasoning-heavy subtasks (debugging, code review, research synthesis)\\n\"\n         \"- Tasks that would flood your context with intermediate data\\n\"","@@ -2857,11 +2871,10 @@ def _build_top_level_description() -> str:\n         \"- Tasks needing user interaction -> subagents cannot use clarify\\n\"\n         \"- Durable long-running work that must outlive the current turn -> \"\n         \"use cronjob (action='create') or terminal(background=True, \"\n-        \"notify_on_complete=True) instead. delegate_task runs SYNCHRONOUSLY \"\n-        \"inside the parent turn: if the parent is interrupted (user sends a \"\n-        \"new message, /stop, /new) the child is cancelled with status=\"\n-        \"'interrupted' and its work is discarded. Children cannot continue \"\n-        \"in the background.\\n\\n\"\n+        \"notify_on_complete=True) instead. Background delegations are NOT \"\n+        \"durable: if the parent session is closed (/new) or the process exits \"\n+        \"before a subagent finishes, that subagent's work is discarded, and \"\n+        \"/stop cancels every running background subagent.\\n\\n\"\n         \"IMPORTANT:\\n\"\n         \"- Subagents have NO memory of your conversation. Pass all relevant \"\n         \"info (file paths, error messages, constraints) via the 'context' field.\\n\"","@@ -3059,19 +3072,13 @@ def _build_dynamic_schema_overrides() -> dict:\n             \"background\": {\n                 \"type\": \"boolean\",\n                 \"description\": (\n-                    \"Run the subagent asynchronously in the BACKGROUND \"\n-                    \"instead of blocking this turn. When true, delegate_task \"\n-                    \"returns immediately with a delegation_id; you and the \"\n-                    \"user keep working while the subagent runs, and its full \"\n-                    \"result re-enters the conversation as a new message when \"\n-                    \"it finishes (similar to terminal background=true + \"\n-                    \"notify_on_complete). The re-injected message includes the \"\n-                    \"original goal/context so you can act on it even after \"\n-                    \"moving on. Single-task only — cannot be combined with the \"\n-                    \"'tasks' batch array. Use for long-running independent work \"\n-                    \"the user shouldn't have to wait on (research, builds, \"\n-                    \"multi-step investigations). Do NOT poll or wait after \"\n-                    \"dispatching — just continue; the result will come to you.\"\n+                    \"DEPRECATED / IGNORED. Single-task delegations always run \"\n+                    \"in the background automatically — you do not need to (and \"\n+                    \"cannot) opt in or out. The result re-enters the \"\n+                    \"conversation as a new message when the subagent finishes; \"\n+                    \"just continue working in the meantime. Setting this has no \"\n+                    \"effect; the parameter remains only for backward \"\n+                    \"compatibility.\"\n                 ),\n             },\n             \"acp_command\": {","@@ -3105,6 +3112,23 @@ def _build_dynamic_schema_overrides() -> dict:\n # --- Registry ---\n from tools.registry import registry, tool_error\n \n+\n+def _model_background_value(args: dict, parent_agent=None) -> bool:\n+    \"\"\"Background flag for the MODEL-facing dispatch path (registry fallback).\n+\n+    Delegations from the top-level agent always run in the background — the\n+    model does not choose. This applies to both a single task and a fan-out\n+    batch (each task becomes its own independent background subagent). The one\n+    exception is a delegation from an orchestrator subagent (depth > 0), which\n+    needs its workers' results within its own turn. The live path is\n+    ``run_agent._dispatch_delegate_task``; this lambda mirrors it for the rare\n+    case the intercept is bypassed. Direct Python callers of ``delegate_task``\n+    keep the historical synchronous default.\n+    \"\"\"\n+    is_subagent = getattr(parent_agent, \"_delegate_depth\", 0) > 0\n+    return not is_subagent\n+\n+\n registry.register(\n     name=\"delegate_task\",\n     toolset=\"delegation\",","@@ -3118,7 +3142,7 @@ def _build_dynamic_schema_overrides() -> dict:\n         acp_command=args.get(\"acp_command\"),\n         acp_args=args.get(\"acp_args\"),\n         role=args.get(\"role\"),\n-        background=args.get(\"background\"),\n+        background=_model_background_value(args, kw.get(\"parent_agent\")),\n         parent_agent=kw.get(\"parent_agent\"),\n     ),\n     check_fn=check_delegate_requirements,"]},{"file":"tools/process_registry.py","hunks":["@@ -1572,6 +1572,70 @@ def _format_async_delegation(evt: dict) -> str:\n     dispatched_at = evt.get(\"dispatched_at\")\n     completed_at = evt.get(\"completed_at\") or _time.time()\n \n+    # ----- Batch (fan-out) completion: consolidated multi-task block -----\n+    # A whole delegate_task fan-out dispatched as one background unit finishes\n+    # together and carries a per-task `results` list. Render every subagent's\n+    # summary in one block so the model gets the consolidated outcome at once.\n+    batch_results = evt.get(\"results\")\n+    if evt.get(\"is_batch\") or isinstance(batch_results, list):\n+        results = batch_results or []\n+        goals = evt.get(\"goals\") or []\n+        n = len(results) if results else len(goals)\n+        total_dur = evt.get(\"total_duration_seconds\", duration)\n+        lines = [\n+            f\"[ASYNC DELEGATION BATCH COMPLETE — {deleg_id}]\",\n+            f\"A background fan-out of {n} subagent(s) you dispatched earlier \"\n+            \"has finished. All ran in parallel and waited on each other; their \"\n+            \"consolidated results are below. You may have moved on since \"\n+            \"dispatching — act on these or re-dispatch if things have changed.\",\n+            \"\",\n+        ]\n+        if isinstance(dispatched_at, (int, float)):\n+            ts = _time.strftime(\"%Y-%m-%d %H:%M:%S\", _time.localtime(dispatched_at))\n+            age = f\" ({_format_age(completed_at - dispatched_at)} ago)\"\n+            lines.append(f\"Dispatched: {ts}{age}\")\n+        if context:\n+            lines.append(f\"Context you provided: {context}\")\n+        if toolsets:\n+            lines.append(f\"Toolsets: {', '.join(toolsets)}\")\n+        lines.append(f\"Role: {role}   Model: {model}   Total duration: {total_dur}s\")\n+        if error and not results:\n+            lines.append(\"--- ERROR ---\")\n+            lines.append(f\"The batch did not complete successfully: {error}\")\n+            return \"\\n\".join(lines)\n+        for r in sorted(results, key=lambda x: x.get(\"task_index\", 0)):\n+            idx = r.get(\"task_index\", 0)\n+            r_status = r.get(\"status\", \"?\")\n+            r_summary = r.get(\"summary\")\n+            r_error = r.get(\"error\")\n+            r_goal = goals[idx] if idx < len(goals) else r.get(\"goal\", \"\")\n+            icon = \"✓\" if r_status in (\"completed\", \"success\") else \"✗\"\n+            lines.append(\"\")\n+            header = f\"--- {icon} TASK {idx + 1}/{n}\"\n+            if r_goal:\n+                header += f\": {r_goal}\"\n+            header += f\"  (status={r_status}\"\n+            if r.get(\"api_calls\"):\n+                header += f\", api_calls={r['api_calls']}\"\n+            if r.get(\"duration_seconds\") is not None:\n+                header += f\", {r['duration_seconds']}s\"\n+            header += \") ---\"\n+            lines.append(header)\n+            if r_status in (\"completed\", \"success\") and r_summary:\n+                lines.append(r_summary)\n+            elif r_summary:\n+                if r_error:\n+                    lines.append(f\"({r_status}: {r_error})\")\n+                lines.append(\"Partial output:\")\n+                lines.append(r_summary)\n+            else:\n+                lines.append(\n+                    f\"(no summary — status={r_status}\"\n+                    + (f\": {r_error}\" if r_error else \"\")\n+                    + \")\"\n+                )\n+        return \"\\n\".join(lines)\n+\n     age = \"\"\n     if isinstance(dispatched_at, (int, float)):\n         age = f\" ({_format_age(completed_at - dispatched_at)} ago)\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}