> ## Documentation Index
> Fetch the complete documentation index at: https://agents.nanonets.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Delegate to Agents

> Native tool that lets one agent, mid-task, hand work to OTHER agents in the same workspace and act on their combined results.

This document covers `delegate_tasks` (display name **"Delegate to Agents"**), the native tool that lets one agent, mid-task, hand work to OTHER agents in the same workspace and act on their combined results.

## Overview

Use this tool when an agent needs to fan work out to specialist agents and then continue once they're all done — e.g. "research these 5 tickers" → spawn one task per ticker on a research agent, wait, then summarize.

Key properties:

* **Independent runs.** Each delegated task is a fully independent run of the target agent with the prompt you provide. The parent cannot steer a child mid-run.
* **Barrier semantics (default).** By default the parent's tool call finishes only when **every** delegated task reaches a terminal state (`completed` / `failed` / `stopped` — any outcome). The parent then resumes with each child's status + output and decides what to do next.
* **Fire-and-forget (`async: true`).** When `async` is on, the parent **starts** the children and **continues immediately** in the same turn — it does not wait and never receives their results. No await group is created, so the parent stays `running`. Use this for work whose output the parent doesn't need back (kick off a long background job, trigger notifications, etc.). See [Async / fire-and-forget](#async--fire-and-forget).
* **Fan-out.** A single task entry can carry an `inputs` array to spawn one child per value (a `{{item}}` placeholder in the prompt/title is substituted per value).
* **Bounded.** Recursion depth, per-call count, and wait timeout are all configurable (see [Configuration](#configuration)). (`timeout_minutes` is ignored when `async` is on — there is nothing to wait for.)
* **Pausing tool (unless async).** `delegate_tasks` is registered as a pausing tool in the orchestrator: while its child awaits are pending, the parent stays in `waiting_for_input` and the agent loop does not advance. An `async` call creates no awaits, so the orchestrator keeps the loop running instead of pausing (see [Flow](#flow)).

It is **enabled by default** on agents that use the default tool set, but a per-agent switch can turn it off (see [Per-agent delegation settings](#per-agent-delegation-settings)).

## Per-agent delegation settings

The agent edit page (Settings tab → **Agent delegation**, `AgentDelegationProfileSection`) exposes the two directions of agent-to-agent collaboration:

1. **Delegate out** (caller) — "Automatically choose from available agents". Backed by `config.auto_delegate` (nil/absent = on). When **off**, the agent can never spawn subtasks: `agent.filterToolsByAgentConfig` strips `delegate_tasks` from its tool list, which also drops the agent roster from the decision prompt (since `canSpawnAgents` keys off that list). When **on**, the agent decides on its own whether to delegate.
2. **Receive delegations** (callee) — "Allow other agents to delegate to this one". Backed by `config.delegation_enabled` (nil/absent = on) plus the `delegation_profile` editor revealed beneath it (see [Delegation profiles](#delegation-profiles)).

These are two **separate sections** on the page (`AgentAutoDelegateSection` and `AgentDelegationProfileSection`).

### Per-task override (home task runner)

The home task runner (`/`, `TaskFeed` home mode) fuses a **"Let other agents help with this task"** toggle into the bottom of the composer (`FusedChatInput`), on by default (the legacy "Assign to an agent" item is removed — the task runs on the default agent and this toggle governs collaboration) — so an ad-hoc task can spawn any of the user's workspace + public agents out of the box (the default agent already has `delegate_tasks` + `auto_delegate` on, and the roster spans workspace + public). Turning it off sends `disable_delegation: true` on `POST /api/chat/message`, which is persisted at INSERT time into `tasks.source_metadata` (`{"disable_delegation": true}`). At decision time `agent.taskDisablesDelegation` strips `delegate_tasks` (base **and** configured instances) from that task's tool list, so it can't delegate regardless of the agent's own config. This is a **disable-only** override: leaving it on respects the agent's own delegation capability.

## Flow

The suspend/resume is built on the existing `task_awaits` + `signals` machinery; the only new signal `source_type` is `child_task`.

1. The worker executes the `delegate_tasks` step. For each (expanded) target it resolves the `agent_group_id` to the runnable target agent and **builds a child task** (`tasks` row with `parent_task_id`, `parent_step_id`, `spawn_depth = parent+1`, `source = 'spawned'`) plus its first user message — **without emitting `new_message` yet**.
2. It creates one `task_await` per child in a group (`source_type = child_task`, `correlation_key = child task id`, `strategy = all_required`, `scheduled_resume_at = now + timeout`).
3. **Only after the awaits are committed** does it emit `new_message` per child, so a fast child can't reach a terminal state before its await row exists (which would otherwise leave the parent hung).
4. The parent task is left in `waiting_for_input`. The chat feed renders a **Delegated tasks** card listing each child with a live status pill (polled every 2s).
5. When a child reaches any terminal state, `TaskTerminalNotifier` enqueues a `child_task` signal (`dedup_key = child id`, so repeated terminal fires are idempotent).
6. The `SignalMatcher` matches the signal to the parent's pending await, completes it, and once the whole `all_required` group is complete emits an `await_completed` event.
7. The orchestrator resumes the parent: it inserts ONE consolidated `task_message` summarizing every child (title / agent / status / output) and a `decide_next_action` step, then flips the task back to `running`.
8. On `scheduled_resume_at` the `AwaitTimeoutProcessor` re-checks each child's liveness. A child that is still **non-terminal** (running, or parked in `waiting_for_input` — which a user can still answer on the child's own task page) is **not** timed out: its await is rescheduled one window forward so the parent stays paused. The parent therefore resumes only once every child reaches a terminal state (via the `child_task` signal). This guarantees a parent never completes while a delegated subtask is still pending.

```mermaid theme={null}
sequenceDiagram
    participant W as Worker (parent)
    participant D as DB / Orchestrator
    participant C as Child tasks
    participant M as SignalMatcher

    W->>D: BuildChildTask × N (tasks + first messages, NO new_message)
    W->>D: CreateGroup(task_awaits, source_type=child_task, all_required)
    W->>C: EmitChildTaskStart × N (new_message)  %% only after awaits exist
    Note over W,D: Parent → waiting_for_input. Feed shows "Delegated tasks" card.

    C->>D: each child runs independently → terminal (completed/failed/stopped)
    D->>M: TaskTerminalNotifier enqueues child_task signal (dedup_key=child id)
    M->>D: FindPendingAwait(child_task, child id) → CompleteAwait
    M->>D: group all_required complete? → emit await_completed
    D->>W: insert consolidated summary message + decide_next_action; status → running
```

### Async / fire-and-forget

When the call sets `async: true`, the parent does **not** block on the children:

1. The worker still **builds each child task** and emits `new_message` (steps 1 + 3 above) — the children run exactly as normal.
2. It **skips the await group** (step 2) entirely — no `task_await` rows, no timeout, no resume path.
3. The tool returns a result with `status: "spawned_async"` (and empty `group_id` / `strategy`). The orchestrator's `isAsyncDelegate` helper reads that status and treats the completion as **non-pausing**: it creates the next `decide_next_action` step and leaves the task `running`, so the agent loop continues in the same turn with the children already started.

The children are normal spawned tasks (`source = 'spawned'`, `parent_task_id` set), so they still appear in the **Delegated tasks** card, the task list "↳ subtask" badge, and the child's "Delegated by …" back-ticker — the parent just isn't waiting on them. Because no await exists, each child's terminal `child_task` signal lands as `no_match` (harmless, the same path as a stopped parent).

## Tool input

```jsonc theme={null}
{
  "tasks": [
    {
      "agent_group_id": "…", // a workspace agent (not self, not another workspace)
      "prompt": "Process {{item}}", // {{item}} substituted per input when fanning out
      "title": "optional",
      "inputs": ["c1", "c2", "c3"], // optional fan-out → one child per value
    },
  ],
  "timeout_minutes": 60, // optional; capped by config; ignored when async is true
  "async": false, // optional; true = start the tasks and continue without waiting (default false)
}
```

* **No `inputs`** → one child per task entry.
* **`inputs` set** → one child per value; `{{item}}` in `prompt`/`title` is replaced (if absent, the value is appended). The per-call limit applies to the **total expanded count**.
* **`async`** → `false` (default) waits for all children and returns their combined output; `true` starts them and continues immediately without their results. Exposed to the LLM, and surfaced in the configured-tool modal as a toggle (**"Run in the background (don't wait for results)"**, `x-display-name`) that an author can pin to a static value.

### Examples

**1. Wait for results (default).** Hand a sub-analysis to a specialist agent and use its output:

```jsonc theme={null}
{
  "tasks": [
    {
      "agent_group_id": "9c1b…",
      "title": "Q3 competitor pricing",
      "prompt": "Research current list pricing for Acme, Globex and Initech and return a short comparison table.",
    },
  ],
  "timeout_minutes": 30,
}
```

The parent pauses in `waiting_for_input`; once the child finishes it resumes with a consolidated summary message and decides what to do next.

**2. Fan-out and wait.** Run the same agent over a list (one child per value):

```jsonc theme={null}
{
  "tasks": [
    {
      "agent_group_id": "9c1b…",
      "title": "Research {{item}}",
      "prompt": "Research the latest financials for ticker {{item}} and return revenue, margin and a one-line outlook.",
      "inputs": ["MSFT", "GOOG", "AMZN"],
    },
  ],
}
```

Spawns 3 children; the parent resumes only after all 3 reach a terminal state.

**3. Fire-and-forget (async).** Kick off background work whose result the parent doesn't need:

```jsonc theme={null}
{
  "tasks": [
    {
      "agent_group_id": "4f7e…",
      "title": "Re-index knowledge base",
      "prompt": "Re-crawl the docs site and refresh the vector index. No reply needed.",
    },
  ],
  "async": true,
}
```

The tool returns `status: "spawned_async"` and the parent continues in the same turn — e.g. it can immediately reply to the user "I've kicked off the re-index in the background" without waiting for it to finish.

## Configuration

### Runtime bounds

```yaml theme={null}
spawn_tasks:
  max_depth: 3 # SPAWN_TASKS_MAX_DEPTH — caps recursion (top-level=0)
  max_children_per_call: 0 # SPAWN_TASKS_MAX_CHILDREN_PER_CALL — 0 = unlimited
  default_timeout_minutes: 60 # SPAWN_TASKS_DEFAULT_TIMEOUT_MINUTES
  max_timeout_minutes: 1440 # SPAWN_TASKS_MAX_TIMEOUT_MINUTES — ceiling for per-call override
```

### Delegation allowlist (optional)

Configure a scoped instance via the standard **Create Configured Tool** modal (Tools → ⚙): pick **Delegate to Agents**, give it a name, set any parameters, and use the **"Agents this tool can delegate to"** multi-select.

* **Leave all unchecked → any workspace agent** (default).
* **Select some → allowlist.** The tool will only delegate to those agents; a task targeting any other agent is rejected at execution.

The selection is stored as the configured tool's allowlist of agents. At execution the binding is injected into the tool args and enforced. The decision-time roster still lists all workspace agents (discoverability); enforcement is at call time.

Because the allowlist value itself is hidden from the model, two things keep it from picking a restricted delegate tool for an agent it can't reach (which would otherwise fail at execution):

* **Description hint (routing):** `loadConfiguredTools` resolves the allowlist `agent_group_id`s to agent names and appends them to the tool's description — e.g. *"This delegate tool can ONLY delegate to: "Pricing Agent" (agent\_group\_id …). To delegate to any other agent, use the delegate\_tasks tool instead."* So the model sees, before calling, which agents each configured delegate tool can reach (`agent.describeDelegateAllowlist` / `buildAgentNameByGroup`).
* **Actionable rejection (recovery):** if it still targets a disallowed agent, the execution error names the allowed agents and points to the open `delegate_tasks` tool (`spawn_agent_tasks.allowedAgentNames`), so it recovers immediately instead of leaving a failed step.

## Discoverability (how the agent knows who to delegate to)

When `delegate_tasks` is enabled, the decision-time system prompt gets an injected roster, parsed as its own **"Delegatable Agents"** section in the debug view:

```
# Agents you can delegate to
You can delegate tasks to the agents below using the delegate_tasks tool. ...
- Research Agent — agent_group_id: <uuid>
    Description: Researches a stock ticker and returns financials.
    Example input: { "ticker": "MSFT" }
- Summarizer — agent_group_id: <uuid>
```

The current agent is excluded (no self-delegation), as are agents with delegation switched off or with no profile description (see [Delegation profiles](#delegation-profiles)). Agent authors can also `@`-mention agents in the instructions editor; resolution is name-based against this roster.

In the admin **decision-log debug view** (`DecisionLogDetail`), this roster renders under the System Prompt breakdown with **one row per delegatable agent** — the same way each tool schema is its own row — so individual agents can be inspected separately (`parseDelegatableAgents` splits the section on the `- <name> — agent_group_id:` bullets).

### Delegation profiles

Each agent can have an LLM-generated **delegation profile** — a short `description` + `example_input` — stored at `config.delegation_profile` and surfaced under each roster entry so `decide_next_action` can pick the right agent.

**Roster eligibility (who shows up as a delegation target).** An agent appears in another agent's roster only when **both** hold (`agent.buildAvailableAgentsSection`):

1. **Delegation is on** — `config.delegation_enabled` is unset or `true` (the default). Setting it to `false` removes the agent from every roster.
2. **It has a profile description** — an agent with no `config.delegation_profile.description` is skipped, since the decider has nothing to match on. (Delegation is allowed by default, but undescribed agents are invisible until a profile exists.)

The roster spans the caller's **workspace agents plus published public agents** (`is_public = TRUE`, from `ListPublicAgents`) shared from other workspaces. Public agents are tagged `(public)` in the prompt and badged **Public** in the debug view. Workspace agents take precedence over a public duplicate (same `agent_group_id`), shown once and untagged. The current agent is always excluded (no self-delegation).

**Cross-workspace execution.** Delegating to a public agent creates the child **in the caller's workspace** (`BuildChildTask` sets the caller's `workspace_id`) — only the shared agent *definition* (config/prompt/tools) is reused; the child's data, integrations, and credentials resolve from the caller's tenant, so there's no cross-tenant data exposure. At execution the tool's allowed-target set is the union of `agentRepo.List(workspaceID)` and `agentRepo.ListPublicAgents()` — both trusted server-side queries; the LLM-supplied `agent_group_id` still can't widen it. Eligibility only gates the decision-time roster; the allowlist + self checks are enforced independently at execution.

* **Generate per agent:** the agent's **Settings** tab has a "Delegation profile" section (`AgentDelegationProfileSection`) with a master **Delegatable** toggle (on by default), a **Generate with AI** button, and editable description/example saved on blur. `delegation_enabled` persists via the standard agent update (`config.delegation_enabled`).
* **Backfill all:** `POST /api/agents/delegation-profiles/backfill` (workspace-scoped; `?force=true` regenerates existing ones) fills in any agents missing a profile.
* **Single endpoint:** `POST /api/agents/:id/delegation-profile` generates + saves one.
* Generation: `services.AgentProfileService.GenerateDelegationProfile` prompts an LLM (gemini-2.5-flash) with the agent's name, instructions, and tools; prompt text lives in `prompts/files/agent/agent_delegation_profile_{system.txt,user.tmpl}`. The call sets `DisableReasoning: true` so thinking tokens don't starve the small output budget (a thinking model otherwise returns truncated/empty JSON).

## UI

* **Parent task feed:** a "Delegated tasks" card lists each child with a live status pill ("3 of 4 complete"); deleted children show as `deleted`. Backed by `useChildTasks` → `GET /api/tasks/query?parentTaskId=…`. For an async delegation (result `status = spawned_async`) the header reads **"Started tasks"** instead, since the parent isn't waiting on them.
* **Child task feed:** a floating "↳ Delegated by \<Agent>" ticker links back to the parent (`feed` response carries a `parent` ref with the parent's agent id).
* **Task list:** spawned children appear in the main list with a "↳ subtask" badge (click → filter to that parent's children). `source=spawned` is included in the default view. A **parent** row shows an "N subtasks" toggle (`TaskTitleCell`). Clicking it expands the delegated-subtasks list (`SubtaskList`) as its **own full-width row** directly beneath the parent — so the parent row's cell heights and alignment never change when it opens. Each child shows its live status, the **agent name** it was delegated to, and a link to its task page; the list loads lazily and live-polls only while open. Expansion state lives in `TasksTable` (via table `meta.subtaskExpansion`). The count comes from `subtask_count` on the task-query response (a correlated `COUNT(*)` of non-deleted children, workspace-pinned).

## Stopping & deleting

Because children are independent runs, stopping/deleting a parent prompts to cascade:

* **Stop** (chat): if the task has delegated subtasks, a dialog offers "Stop only this" vs "Stop this & N subtasks". Cascade stops each active descendant, cancels their steps **and their pending awaits** (so an in-flight child signal can't resume a just-stopped task — `delegate_tasks` does not fire wake-up signals during teardown). Stopping an already-terminal parent with the cascade flag still stops orphaned children.
* **Delete** (single row + bulk): same prompt; cascades a soft-delete to descendants (`include_subtasks`). Note: delete is soft-delete only — it hides children but does not halt ones still executing.

## Edge cases & guarantees

* **Self / same-group delegation** is rejected (infinite-loop guard), as is any `agent_group_id` outside the workspace (tenant isolation / BOLA) or outside the configured allowlist.
* **Recursion** is capped by `max_depth`; a child may delegate further only until the cap.
* **Duplicate resume** is prevented by `dedup_key = child id` on the signal + the race-safe `CompleteAwait` CAS.
* **Spawn↔await race** is closed by committing all child awaits before emitting any child's `new_message`.
* **Parent stopped/deleted while children run:** the parent's awaits are cancelled, so a child's terminal signal finds no pending await (`no_match`, harmless).
* **Async (`async: true`):** no await group is created, so the parent never blocks and the children's terminal signals always land as unmatched (harmless). The parent gets no results back and cannot be resumed by the children — choose this only when the output isn't needed. `timeout_minutes` is ignored. The recursion-depth, allowlist, self / cross-workspace, and per-call-count guards all still apply (async changes only the wait, not who can be spawned).
