Skip to main content
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.
  • 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). (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).
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

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).
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.

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

  • 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.
  • asyncfalse (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:
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):
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:
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

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_ids 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:
The current agent is excluded (no self-delegation), as are agents with delegation switched off or with no profile description (see 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 onconfig.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 useChildTasksGET /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).