Skip to content
Pilotoo
v0.1

Subagents

An orchestrator agent can hand work to other agents: a researcher gathers, a writer drafts, an analyst checks. Each handoff is a subagent, built from that agent’s own definition. Use this when one prompt would otherwise carry too many jobs, or when some of the work should run while the user keeps chatting.

The runtime builds on DeepAgents, which provides the task tool used for delegation (see Concepts).

The repository’s sample orchestrator lists three agents it can delegate to. It also needs the built-in task tool.

.pilotoo/agents/orchestrator.yaml
runtime:
profileId: default
tools: []
builtinTools: [task]
subagents: [researcher, writer, analyst]

runtime.subagents lists the ids of other agents, at most AGENT_LIMITS.subagents (8), none twice. The agent manager requires task in builtinTools for a non-empty list.

Each subagent runs with its own prompt, the model and params of its own profile, its own tools, builtinTools and skills, and the tool policy. The orchestrator learns about it from its description, so write that as “when to use this agent”.

  • Absent keeps the one general-purpose subagent DeepAgents adds.
  • A list, even an empty one, makes the choice exactly those agents. DeepAgents cannot leave its general-purpose subagent out of a graph, so task refuses a call naming it while the model still reads its line in the tool description.

Subagents resolve when the graph is built, through the same AgentDefinitionSource and in the caller’s identity. A tenant’s agents and the system agents both resolve; another tenant’s never do. The depth is one: a subagent’s own list is not read.

  • A subagent that is missing, a draft, invalid or on an unknown profile fails the run at start, naming the orchestrator and the subagent. It is never silently dropped.
  • A failing subagent (a provider error or timeout) reaches its parent as an error result of that task call and does not end the run. The parent decides what to do. The kit never retries a failed subagent itself, and a cancelled run still stops.
  • Editing a subagent, its file or its profile reaches the orchestrator’s next run, because the graph cache version includes every subagent’s definition and profile.
  • A subagent’s model calls are recorded under the subagent’s own provider and model. The run summary keeps the orchestrator’s.

Every PilotooModule.forRoot host wires BackgroundTaskService into its runner as the tasks launcher. The orchestrator then gets DeepAgents’ async tools beside task, and each subagent runs as a task that can outlive the turn.

Behavior What happens
Each task has its own conversation Owned by the caller and hidden from GET /conversations. It opens like any thread. SUBAGENT_STARTED carries taskId and childThreadId.
Results come back on their own A task nobody waits on posts a turn on the parent thread once no run is active there and no decision is pending. It is a human message marked task; a client should render it as a notice, not as the reader’s message.
One run per thread A run:start on a thread with a run in flight, or with turns already queued, is queued and starts when the thread frees. A queued message that keeps failing to start is given up with INVALID_COMMAND.
Moving to the background run:start on a thread waiting on task moves it to the background and runs the message after the run. run:detach { runId, toolCallId? } does it without a message (INVALID_COMMAND when nothing waits). run:cancel and cancel_async_task still cancel.
Continuing a task run:start on a task’s conversation interrupts and continues the task, or runs a settled one again.
Restart Tasks left running are marked interrupted at boot and announced to their parent. The announcement runs without the owner’s credentials, so the agent is told to offer a new start rather than make one.

A host assembling its own runner without the module passes tasks: new InMemoryBackgroundTaskLauncher({ onSettled }) (from @pilotoo/agent-runtime), or none, which keeps DeepAgents’ in-run task.

The backgroundTaskStorage port (BackgroundTaskStorage, token BACKGROUND_TASK_STORAGE) is PostgreSQL by default (table created on first use; see Storage) and SQLite in the local backend. It must share storage with conversationStore, which hides a task’s conversation and deletes it, at any depth, with its parent. A host that supplies the persistence ports and not this one runs without background tasks (one log line at boot), and its agents keep DeepAgents’ in-run task.

On an agent that runs tasks, the model reads an announcement turn as the result of a check_async_task call on that task, so the subagent’s output never carries a user’s authority. The checkpoint and history keep the marked human message.

useConversation from @pilotoo/chat-core gives detach(toolCallId?) and sendableWhileRunning. A wake-up turn is a ChatMessage with task, and SubagentActivity.task carries taskId and childThreadId. SUBAGENT_FINISHED.detached marks a subagent moved to the background. @pilotoo/ui/chat and @pilotoo/chat-ui draw a task call as a subagent card and a wake-up as a notice.

The agent manager refuses an unknown, draft, malformed or own id as an issue on runtime.subagents. Deleting or drafting an agent that others name is a 409 referenced-agent with referencedBy. GET /agent-admin/options lists the active agents the caller can read as agents (id, name, description, source) for the editor.

After a reload, a task call in the thread history carries its subagent (name, description), so the tool-call row renders as it did live.