Sub-agent runtime
A sub-agent is a full agent loop with its own session id, capability profile, and iteration budget. The parent sees only the final result, which is what keeps delegation from flooding the parent’s context — a sub-agent can burn fifty tool calls exploring a codebase and the parent’s context only grows by one tool result: a summary.
Spawning from the agent tool
The parent model spawns a sub-agent the same way it calls any other tool —
tools/agent.ts defines an agent tool with two required parameters,
task (a short label) and prompt (the actual instruction):
interface AgentParams {
task: string;
prompt: string;
agentType?: string; // optional provider override
forkContext?: boolean; // start with the parent's full conversation instead of just the prompt
readOnly?: boolean; // default true → explore mode; false inherits the parent's mode
run_in_background?: boolean; // return at once; result arrives as a task notification
}By default a sub-agent starts with an isolated context — only the
prompt string, nothing from the parent conversation. forkContext: true
switches this: agent/subagent.ts’s executeSubagent() calls
sessionStore.fork(config.forkFrom), cloning the parent’s session so the
sub-agent’s transcript begins with the full parent history. Use this when
the sub-agent needs to continue work the parent already has context for;
leave it off for a fresh, independent investigation (exploring, reviewing) —
which is both cheaper and keeps the sub-agent from anchoring on the parent’s
assumptions.
Background spawns and task notifications
run_in_background: true changes only when the parent gets the result.
executeSubagent does all the setup it always does (session, registry entry,
agent_start), then wraps the run in runToCompletion(). Foreground awaits
it; background fires it off and returns Started in the background as <id>.
When it settles, notifyTask(parentSessionId, …) (agent/task-notify.ts)
formats a <task-notification> and hands it to the sink server.ts
installed:
sink(sessionId, text, notice)
→ notice StreamEvent (the user sees one line)
→ pendingNotifications[session] (250 ms coalesce timer)
→ flush:
session gone → drop
startingTurns.has(id) → reschedule (loop not built yet)
activeLoops.get(id) → loop.steer(text, id, "task_notification")
otherwise → runSessionTurn({ prompt, synthetic: "task_notification" })Three details keep this correct:
startingTurns.runSessionTurnbuilds its loop behind anawaitbeforeactiveLoops.set. A flush in that window would see an idle session and start a second, concurrent turn, so the set marks the window as busy.run()no longer clearspendingSteers. A notification can be steered onto a loop between registration andrun()starting.takeUndeliveredSteers()already empties the queue at the end of every run, so the reset only ever dropped messages.- Undelivered notifications go back as notifications. A steer the run
ended before draining returns as a
PendingSteercarrying itssynthetickind. A user’s steer is re-queued as a follow-up, as before. A notification re-entersqueueTaskNotification, which delivers it to whatever turn comes next, or starts one.
The persisted message is synthetic: "task_notification": harvest skips it,
title extraction skips it, and the TUI renders it as a one-line notice on
resume. tasks.notify: false / FREECODE_TASK_NOTIFY=0 makes
run_in_background fall back to the foreground rather than spawning a
sub-agent whose result could never arrive.
Role definitions and their modes
Five built-in roles are declared in agent/types.ts as SUBAGENT_DEFINITIONS,
each with a description and a default read-only flag:
| Role | Default | Purpose |
|---|---|---|
explorer | read-only | find patterns, understand architecture |
reviewer | read-only | find bugs, security issues, performance problems |
tester | can write | write and run tests, verify functionality |
summarizer | read-only | condense long conversations or documents |
verifier | read-only | independently, adversarially check completed work — see below |
The read-only flag isn’t just documentation — it decides the sub-agent’s agent mode:
const readOnly = config.readOnly ?? SUBAGENT_DEFINITIONS[config.type].defaultReadOnly;
const result = await loop.run({
...
agentMode: readOnly ? "explore" : "build",
});A reviewer sub-agent runs in explore mode, which the permission engine
hard-denies mutation on regardless of any allow rule — the isolation is
enforced by the same mode machinery every top-level session uses, not by a
separate sandbox. tester is the one role that defaults to build, since
its whole job is to write and run test files.
Isolation, not a lighter loop
executeSubagent() builds the child with createAgentLoop() — the same
function that builds the top-level loop — but disables two behaviors that
only make sense for durable user conversations:
const loop = createAgentLoop(id, {
maxIterations: config.maxIterations ?? 20,
memoryExtraction: false, // delegated machine work, nothing durable to learn from it
redirect: false, // a subagent is turn-capped and disposable
sessionStore,
});Turning off memoryExtraction avoids multiplying one user turn into several
memory-extraction calls (one per sub-agent spawned). Turning off redirect
(the trajectory-redirection system covered in the eval/agent-loop docs)
reflects that re-planning belongs to whoever spawned the sub-agent, not the
disposable child itself.
Bus events and progress reporting
A spawn publishes subagent.started and, on completion or error,
subagent.completed on the event bus:
BusEvents.subagentStarted(id, config.type, "", config.taskPrompt);
// ... loop runs ...
BusEvents.subagentCompleted(id, config.type, "", result.success, result.message);The parent’s frontend renders these as a nested progress indicator, but the
parent model never sees the sub-agent’s intermediate tool calls — only the
final SubagentResult (success, content, message, turnCount,
iterationCount) comes back as the agent tool’s result.
The verifier: an adversarial exception
Every other role reports what it found; verifier is asked to assign a
verdict the main agent is not allowed to assign itself. agent/subagent.ts
parses the verifier’s final message for a VERDICT: PASS|FAIL|PARTIAL line:
export function parseVerdict(text: string): Verdict {
const m = text.match(/VERDICT:\s*(PASS|FAIL|PARTIAL)/i);
if (!m) return "PARTIAL"; // a missing/garbled verdict is never a false PASS
return m[1].toUpperCase() as Verdict;
}A missing or unparseable verdict defaults to PARTIAL — unverified — rather
than passing by default, so a verifier that fails to produce clean output
never silently green-lights a change. Verification only triggers for
non-trivial diffs (VERIFIER_MIN_FILES = 3), and a FAIL verdict can drive
at most MAX_VERIFIER_ATTEMPTS = 2 fix-and-reverify cycles before the loop
gives up and surfaces the failure, bounding cost and preventing an infinite
verify → fix → verify spiral.
Failure handling
executeSubagent() wraps the whole run in try/catch: an exception (provider
error, thrown by a tool) is caught, turned into
{ success: false, message }, still reported via subagent.completed, and
returned to the parent as a tool result rather than propagating up and
killing the parent’s own turn. A sub-agent failing is just information the
parent model receives and can act on — retry, try a different approach, or
surface the failure to the user.