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

task Tool — Subagent Delegation Orchestrator spawns fresh-context subagents that share the workspace and deliver file artifacts via complete() Orchestrator Agent MAIN_INSTRUCTIONS · main conversation thread task({ subagent_type, description, prompt }) parallel fan-out Subagent A type: "research" tools: tavily_search, fs workspace: shared 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] Subagent B type: "general" tools: standard set workspace: shared 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] Subagent C type: "coding" tools: fs, execute workspace: shared 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] { summary, artifacts[], status } Reconcile — read_file artifacts files persist in the shared workspace for the task lifetime Subagents share the parent workspace 1:1 (no chroot); the workspace root is enforced by NodeSandbox.isUnderRoot. No nested task delegation; no ask_user_question / confirm — subagent has no user channel.

Delegation Flow

  1. Spawn — Orchestrator calls task({ subagent_type, description, prompt }) to launch a subagent
  2. Context — a fresh RuntimeContext reuses the parent’s sandbox and workspace 1:1 (no chroot, no subdirectory)
  3. Run — Subagent executes autonomously via runAgentLoop({ input: { kind: "subagent" } }) with its own tool set, step limit, and the TASK_INSTRUCTIONS system prompt
  4. Deliver — Subagent writes file artifacts to the shared workspace and calls complete({ summary, paths }) (the stop tool) to package delivery
  5. Returntask extracts the complete invocation and returns { summary, artifacts[], skipped?, status, steps }; artifacts carry real workspace paths, so the orchestrator can read_file any 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:

InheritedNot inherited
taskId, userIdConversation 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)
timezonesubagentRecords (no nesting)

Consequences:

  • Subagents cannot ask the user questions — ask_user_question and confirm aren’t in the subagent definition’s toolKeys
  • Subagents cannot spawn further subagents — task isn’t in their toolKeys either (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’s isUnderRoot check
  • Subagents use the TASK_INSTRUCTIONS system prompt, not MAIN_INSTRUCTIONS — selected via kind: "subagent" passed to runAgentLoop

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:

FieldPurpose
typeSlug used as enum value (e.g., “general”, “research”, “coding”)
descriptionHuman-readable — the LLM uses this to choose which subagent to spawn
modelResolved model for this subagent (from tier config)
maxStepsStep limit
stopConditionsWhen to stop
toolKeysAvailable tools for this subagent
compactionModelModel used for the subagent’s context compaction
instructionsAgent-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

  1. Validate — Resolve the subagent definition by subagent_type; throw if unknown
  2. Build RuntimeContextruntimeContextSchema.parse({ taskId, userId, sandbox, workspace, timezone }) reusing the parent’s sandbox + workspace 1:1
  3. SpawnrunAgentLoop({ context, config: { model, maxSteps, toolKeys, stopConditions, compactionModel }, input: { kind: "subagent", messages: [kickoff], extraInstructions } }) selects TASK_INSTRUCTIONS and the resolved tool set; the prompt becomes the single kickoff message
  4. Forward output — Each UI chunk is relayed to the client via DataPartEvent.TOOL_STREAM for live progress (plus a heartbeat that re-arms the parent’s chunkMs timer)
  5. Extract complete invocationextractCompleteCall(steps) finds the final complete tool call/result
  6. Track — Push a record to context.subagentRecords for billing
  7. Return TaskOutput — artifacts carry real workspace paths, so the orchestrator can immediately read_file any of them (no path translation)

Usage Tracking

Each subagent run is pushed as a record onto context.subagentRecords:

FieldPurpose
defWhich subagent definition was used
toolCallIdLinks to the parent tool call
descriptionThe short label from the task call
stepsHow many LLM steps the subagent took
usageToken consumption (input + output)
status"completed" / "error" / "aborted"
startedAt / completedAtDuration 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:

  1. Context bloat — long subagent outputs (reports, code, analysis) ballooned the orchestrator’s context every time task was called.
  2. 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.

Was this page helpful?