Subagents
The task tool spawns isolated child agents for parallel subtask delegation with usage tracking
A subagent is a fresh context over the parent’s sandbox
The task tool enables the agent to spawn ephemeral subagents that execute subtasks autonomously and deliver a
structured set of file artifacts. Each subagent runs with a fresh context — no conversation history, no parent
runtime state, its own tool set and step limit — but shares the parent’s sandbox and workspace 1:1 (the same model
as Agent Team members). This enables parallel execution of independent work streams without
bloating the orchestrator’s context.
The tool resides in @zapvol/backend/src/tools/tools/task.tool.ts.
Architecture
Delegation Flow
- Spawn — Orchestrator calls
task({ subagent_type, description, prompt })to launch a subagent - Context — a fresh
RuntimeContextreuses the parent’ssandboxandworkspace1:1 (no chroot, no subdirectory) - Run — Subagent executes autonomously via
runAgentLoop({ input: { kind: "subagent" } })with its own tool set, step limit, and theTASK_INSTRUCTIONSsystem prompt - Deliver — Subagent writes file artifacts to the shared workspace and calls
complete({ summary, paths })(the stop tool) to package delivery - Return —
taskextracts thecompleteinvocation and returns{ summary, artifacts[], skipped?, status, steps }; artifacts carry real workspace paths, so the orchestrator canread_fileany of them directly — no translation hop
Multiple subagents can run in parallel when their tasks are independent. Because the workspace is shared and flat,
concurrent subagents must write to distinct output names to avoid clobbering — the delegation prompt should name the
expected files. Files persist for the lifetime of the parent task’s sandbox, so the orchestrator can re-read them at any
later step.
Context Isolation
The subagent receives a fresh RuntimeContext built by runtimeContextSchema.parse() that reuses the parent’s
sandbox and workspace but carries none of the parent’s conversational or runtime state:
| Inherited | Not inherited |
|---|---|
taskId, userId | Conversation history |
sandbox (the parent’s, 1:1) | todos, reminders |
workspace (the parent’s real path) | writer (parent-only stream channel; progress relayed via tool-stream) |
timezone | subagentRecords (no nesting) |
Consequences:
- Subagents cannot ask the user questions —
ask_user_questionandconfirmaren’t in the subagent definition’stoolKeys - Subagents cannot spawn further subagents —
taskisn’t in theirtoolKeyseither (no recursion) - Subagents share the workspace rather than a chroot: isolation is by naming, not path confinement. The workspace root
is still enforced at the provider level by
NodeSandbox’sisUnderRootcheck - Subagents use the
TASK_INSTRUCTIONSsystem prompt, notMAIN_INSTRUCTIONS— selected viakind: "subagent"passed torunAgentLoop
SubagentDefinition
Subagent types are resolved by the business layer from database agent records + tier config, then passed into the
tool’s build deps (ToolBuildDeps.subagentDefs). The tool reads these definitions at construction.
Each definition contains everything runAgentLoop needs:
| Field | Purpose |
|---|---|
type | Slug used as enum value (e.g., “general”, “research”, “coding”) |
description | Human-readable — the LLM uses this to choose which subagent to spawn |
model | Resolved model for this subagent (from tier config) |
maxSteps | Step limit |
stopConditions | When to stop |
toolKeys | Available tools for this subagent |
compactionModel | Model used for the subagent’s context compaction |
instructions | Agent-specific instructions (from DB), passed as extraInstructions |
If no subagentDefs are provided, createTools() returns {} — the task tool is not registered and the LLM never
sees it.
Dynamic Schema
The tool input schema is built dynamically from available definitions. The subagent_type parameter becomes an enum
of available types with descriptions; description is a short label and prompt carries the actual delegation:
const dynamicTaskInputSchema = z.object({
subagent_type: z
.enum(agentTypes as [string, ...string[]])
.describe(`The type of subagent to spawn. Available types:\n${descriptions}`),
description: z.string().max(80).describe("Short label (3-7 words) for UI display and logs. Not the task itself."),
prompt: z
.string()
.describe(
"Self-contained task delegation. Must include goal, all required context (subagent has no conversation " +
"history), expected output / file deliverables, and scope boundaries.",
),
});
description and prompt are deliberately separate: the short label is what the UI surfaces in the task card title and
what the execution log records, while the prompt is the long-form delegation that the subagent actually receives as
its first user message.
Output Shape
The tool returns a structured TaskOutput:
interface TaskOutput {
summary: string; // From complete({ summary }) — falls back to stream.text if subagent never called complete
artifacts: Artifact[]; // Files validated by complete; real workspace paths (shared workspace, no translation)
skipped?: SkippedPath[]; // Paths the subagent claimed but the complete tool failed to validate
status: "completed" | "aborted" | "error";
steps: number;
}
interface Artifact {
path: string; // Absolute path in the shared workspace (e.g. /workspace/report.md)
size: number;
fsType: "file" | "directory" | "symlink";
modifiedAt?: string;
}
The compact() of the task tool keeps summary, status, and the artifact path/size/fsType — even after compaction
the orchestrator can still resolve and re-read every delivered file.
Execution
- Validate — Resolve the subagent definition by
subagent_type; throw if unknown - Build
RuntimeContext—runtimeContextSchema.parse({ taskId, userId, sandbox, workspace, timezone })reusing the parent’s sandbox + workspace 1:1 - Spawn —
runAgentLoop({ context, config: { model, maxSteps, toolKeys, stopConditions, compactionModel }, input: { kind: "subagent", messages: [kickoff], extraInstructions } })selectsTASK_INSTRUCTIONSand the resolved tool set; thepromptbecomes the single kickoff message - Forward output — Each UI chunk is relayed to the client via
DataPartEvent.TOOL_STREAMfor live progress (plus a heartbeat that re-arms the parent’schunkMstimer) - Extract
completeinvocation —extractCompleteCall(steps)finds the finalcompletetool call/result - Track — Push a record to
context.subagentRecordsfor billing - Return
TaskOutput— artifacts carry real workspace paths, so the orchestrator can immediatelyread_fileany of them (no path translation)
Usage Tracking
Each subagent run is pushed as a record onto context.subagentRecords:
| Field | Purpose |
|---|---|
def | Which subagent definition was used |
toolCallId | Links to the parent tool call |
description | The short label from the task call |
steps | How many LLM steps the subagent took |
usage | Token consumption (input + output) |
status | "completed" / "error" / "aborted" |
startedAt / completedAt | Duration measurement |
The business layer uses these records for credit billing — subagent token consumption counts toward the parent task’s usage.
Delegation Guidance
The tool’s prompt includes structured guidance for writing effective delegations:
When to delegate: Complex multi-step tasks, independent work that benefits from parallelization, heavy reasoning that would bloat the orchestrator thread.
When NOT to delegate: Trivial tasks (a few tool calls), tasks that don’t reduce complexity, or where splitting adds latency without benefit.
Good prompt: Clear goal, complete context (the subagent has no conversation history), explicit file deliverables
(the subagent will return them through complete), and scope boundaries.
Bad prompt: Vague goals, references to “what we discussed” (invisible to subagent), no output specification.
Why Files, Not Inline Text
Earlier versions of task returned the subagent’s final stream.text as the result. This created two problems:
- Context bloat — long subagent outputs (reports, code, analysis) ballooned the orchestrator’s context every time
taskwas called. - Lossy compaction — when context compression dropped the inline result, the deliverable was gone.
The current design separates signal from artifact: the orchestrator’s context only carries summary plus path
pointers. The actual deliverables live as files in the shared workspace, persistent for the parent task’s lifetime,
and the orchestrator pulls them in on demand via read_file. This mirrors how Claude Code’s Read/Write tools let it
operate over arbitrarily large codebases without exhausting context.