A Tour of the Harness · v1 · M9

Subagents

A subagent is not a second engine. It is the same loop, entered again with less. Four stops, about half an hour.

Built M9, shipped in 1.0
Kept in sync by a test

The claim to test as you read: everything on this page is the machinery to re-enter the loop you already read, not a parallel implementation of it (ADR-0006, ADR-0054). If you find a second turn implementation anywhere here, that is a bug worth reporting.

flowchart TD
    m["the model calls task, maybe twice in a row"] --> ex["tools/execute.py
consecutive task calls group"] ex --> f1["subagent A"] ex --> f2["subagent B"] f1 --> loop["the same run_turn,
a fresh session, a narrowed policy,
the parent's remaining budget"] f2 --> loop loop --> res["one text result each,
in call order"] res --> back["back to the parent's context
as ordinary tool results"]

Stage 1. Re-entering the loop about 15 minutes

1What an agent is

tools/builtin/task.py agents/definition.py agents/discovery.py

Look for: TaskTool subject AgentDefinition Found scan _agent

task.py is short on purpose — about fifty lines. It does four things: check the call is valid, look the agent up, hand the work to spawn, and return one string. subject is the short label the status bar shows, so a human watching four rows scroll past can tell them apart.

An agent is nothing more than a markdown file with front matter:

  • a name and a description
  • which tools it is allowed to use
  • optionally, a different model to run on

Nothing executable, nothing in Python — that is the rule for every extension (ADR-0018). discovery.py searches the usual layered way: project files win over user files, and the first definition of a name wins. Found remembers where each one came from, so edgar doctor and error messages can say exactly which file they mean.

One thing a subagent does not get: the parent's transcript. It starts from a prompt and a fresh session. That is the whole point of using one — the parent's context stays small — and it is also why a subagent can never ask the parent a question.

2Narrowing, not widening

agents/spawn.py

Look for: spawn SpawnLimits HARD_DEPTH subagents_config narrow_mode _cap _subset

This file carries edgar's second standing rule: humans widen, machines tighten (ADR-0021). Read narrow_mode and _subset together and check there is no way a child ends up with a looser mode or a tool its parent did not have. If an agent file asks for a tool the parent lacks, it quietly gets only the tools both have — never more.

  • HARD_DEPTH — a constant, not a setting. A subagent may spawn one of its own, but the nesting stops at this depth no matter what the config says.
  • _cap — splits the parent's remaining budget rather than handing out a fresh one, so ten subagents cannot cost ten times the cap.
  • SpawnLimits — one dataclass holding concurrency, depth and per-child ceilings, so all the rules sit in one readable place.

Take with you: every limit here is checked at the moment of spawning, where a clear error is possible, rather than mid-turn where it would only produce a confusing failure.

Stage 2. Several at once about 10 minutes

3Fan-out, and watching it happen

tools/execute.py cli/statusbar.py

Look for: execute execute_many _fan_out_group FRAMES Status StderrLine rows terminal_ready

Tool calls from one response normally run one after another — that is Core's rule, and it has not changed. execute_many adds exactly one exception: a consecutive run of task calls runs at the same time [TOOL-12]. Find _fan_out_group and check the two things that matter:

for each tool call in the response, in order:
    if this call is "task" and the next ones are too:
        group the run of "task" calls together
        run the whole group at the same time
        keep the results in the original call order
    else:
        run this one call by itself, then move on
  • An ordinary tool call between two task calls breaks the group — no surprise concurrency slips in.
  • Results always come back in call order, whichever subagent actually finishes first.
  • A subagent that fails becomes an error result for just its own call. It never cancels its siblings.

statusbar.py earns its size on the terminal handling, not the logic. It subscribes to the event bus like every other output component (ADR-0011) and draws one row per running agent [SUB-9].

  • terminal_ready — refuses to draw unless stderr is a real terminal, so piped and scheduled runs stay clean.
  • StderrLine — exists so that anything else printing to stderr doesn't land in the middle of a half-drawn frame.

Stage 3. Choosing a model about 10 minutes

4Three mechanisms, kept apart

providers/routing.py providers/fallback.py

Look for: Role RoutingContext Route Selection select_model check_capabilities routes_from_config Candidate NoFallback choose next_provider chain_from_config

Routing, escalation and fallback get mixed up together almost everywhere else. Here they are three separate files, and one of them is not built yet (ADR-0013).

  • Routing decides up front. select_model is a pure function: a RoutingContext plus a list of Route rules in, a Selection out. That is why it is property-tested rather than mocked.
  • Fallback goes sideways when a provider is unreachable, and never lowers capabilities — choose skips any candidate that cannot do what the selected model could.
  • Escalation goes upward, on a capability failure. It belongs to v3 — there is no file for it yet.

check_capabilities is where a model with no tool support gets refused, and where matters: at selection, with a message naming the model and the rule that picked it — not as a provider error halfway through a turn [ROUTE-6]. NoFallback is the honest ending: when the chain runs out, the run stops and says so, instead of quietly reaching for something you never configured (ADR-0023).

flowchart LR
    ctx["role, prompt size,
agent, config"] --> sel["routing
select_model, pure"] sel --> cap{"can it use tools,
if this turn needs them?"} cap -->|no| refuse["refused here, by name"] cap -->|yes| run["run the turn"] run -->|"provider unreachable"| fb["fallback
sideways, same capabilities"] fb -->|"chain exhausted"| nf["NoFallback: stop and say so"] run -->|"capability failure"| esc["escalation
upward, capped, announced — v3"]

Sizes

Lines of code: blank lines and comments do not count, docstrings do. just loc prints the current totals.

FileLines of codeWhat it is
tools/builtin/task.py~54The tool: validate, look up, spawn, return one string
agents/definition.py~27An agent is a markdown file with front matter
agents/discovery.py~82Layered search, and where each one came from
agents/spawn.py~135Narrowed policy, split budget, capped depth
tools/execute.py~150The pipeline, and the one concurrent exception
cli/statusbar.py~153One row per running agent, on stderr
providers/routing.py~99Declarative rules, resolved by a pure function
providers/fallback.py~72Sideways on unreachability, never downward

Source: just loc, which counts with tests/support/budget.py. Rounded; the tour's test fails if a figure drifts more than 25 lines of code from the code.