Chapter 84 · Agent loops and control
Subagents, and when splitting helps
Splitting a run across several agents is usually the wrong answer to a context problem. The three cases where it is right, what it costs, and the permission mistake that makes it dangerous.
11 min read
Subagents are architecturally appealing: separate concerns, bounded contexts, specialised prompts. They are also the point at which a system becomes genuinely hard to debug, and most of the reasons given for them are better solved another way.
This chapter is the honest case: three situations where splitting helps, and the costs that apply in all of them.
What splitting is usually a workaround for
| Stated reason | Better fix |
|---|---|
| "The context is too large" | Trim the context |
| "Too many tools to choose from" | Filter the surface per request |
| "The prompt is getting complicated" | Remove rules that do not earn their place |
| "Different tasks need different models" | Route by tier — one agent, several models |
All four are real problems and none needs a second agent. Splitting adds an interface, a permission boundary, a failure mode and a debugging step, and the four fixes above cost none of those.
A subagent is not a way to simplify a prompt. It is a way to isolate a run, and isolation is only worth its cost when something actually needs isolating.
Three cases where it is right
1. A genuinely separate context that must not leak
A summarisation step over a long document should not see the conversation, the tool definitions or the other retrieved chunks. Giving it only the document is both cheaper and safer.
// A subagent with a deliberately empty context. It cannot be influenced
// by the main run and cannot leak into it except through its return value.
export async function summariseDocument(documentId: string, scope: Scope) {
const text = await getDocumentText(documentId, scope)
return callModel({
model: models.routing,
// ⚠️ No conversation, no tools, no other sources. The isolation IS
// the point — and it also makes prompt injection in the document
// unable to reach the main run's tools. Chapter 117.
messages: [{ role: 'user', content: prompt('summarise', { text }) }],
tools: [],
})
}This is the strongest case and it is barely an agent — no loop, no tools, one call. That is typical: most useful "subagents" are isolated single calls rather than nested loops.
2. Parallel branches of a decomposed question
Chapter 47's parallel decomposition, where each branch has its own scope lock.
// "Compare Henderson's terms with Acme's." Two branches, each locked to
// one account, running concurrently.
const branches = await Promise.all(subQuestions.map((sub) =>
runLoop(newRun(sub.question, scope, BRANCH_LIMITS, {
// ⚠️ Each branch gets its own lock, which is how chapter 76's
// scope-drift rule survives a legitimate cross-account question.
lockedAccount: sub.account,
maxSteps: 3,
}))))3. A different trust level
The case that genuinely requires separation rather than merely benefiting from it.
// Content from an untrusted source — a customer email, an uploaded
// document — is processed by an agent with NO tools and a narrow scope.
// Its output is data, not instructions, to the main run.
export async function extractFromUntrusted(content: string): Promise<Extraction> {
const result = await callModel({
model: models.routing,
messages: [{ role: 'user', content: prompt('extract', { content }) }],
tools: [], // ⚠️ nothing to call
})
// And the output is schema-validated before it re-enters the main run,
// so an injection cannot arrive as a free-text instruction.
return ExtractionSchema.parse(JSON.parse(result.text))
}The permission mistake
The one that makes subagents dangerous rather than merely complicated.
// ❌ A subagent with its own scope, or worse, a service scope. This is
// the confused deputy from chapter 70, created on purpose.
const sub = await runLoop(newRun(subQuestion, SERVICE_SCOPE, limits))
// ✅ The subagent inherits the caller's scope, narrowed, never widened.
const sub = await runLoop(newRun(subQuestion, narrow(scope, { tools: ['check_inventory'] }), limits))// Enforced structurally, because "remember to pass the scope" is not a
// control.
export function narrow(scope: Scope, restrict: Restriction): Scope {
return {
...scope,
// ⚠️ Intersection only. A narrow() that can add a capability is not
// a narrowing, and this is the whole invariant.
capabilities: scope.capabilities.filter((c) => restrict.capabilities?.includes(c) ?? true),
accountIds: restrict.accountIds
? scope.accountIds.filter((a) => restrict.accountIds!.includes(a))
: scope.accountIds,
classifications: scope.classifications,
}
}
test('narrow cannot widen', () => {
const narrowed = narrow(SCOPE.insideSales, { capabilities: ['apply_credit'] })
expect(narrowed.capabilities).not.toContain('apply_credit') // not held, not granted
})What it costs
Measured on Atlas, the parallel-decomposition case:
| Single agent | Two subagents | |
|---|---|---|
| p50 latency | 5.9 s | 3.4 s |
| Total tokens | 31,000 | 22,000 |
| Cost | $0.041 | $0.029 |
| Correct | 0.88 | 0.91 |
| Trace readability | one sequence | two interleaved |
| Failure modes | 5 | 9 |
Faster, cheaper and slightly more correct — and the last two rows are the price. Chapter 89 is largely about the first of them.
The four new failure modes:
// 1. A subagent halts and the parent does not notice.
// 2. Subagents disagree, and the parent picks one without saying so.
// 3. A subagent's budget is unbounded, so the parent's is meaningless.
// 4. A subagent's error reaches the user as the parent's error, with no
// indication of which branch failed.// All four are handled by making the parent explicit about branches.
export interface BranchResult {
branch: string
state: RunState // answered | halted | suspended
trace: TraceEntry[]
spendUsd: number
}
export async function runBranches(branches: Branch[], parent: Run): Promise<BranchOutcome> {
// ⚠️ The parent's budget is divided, not duplicated. Otherwise two
// branches cost twice the limit the parent was given.
const each = { ...parent.limits,
maxSpendUsd: parent.limits.maxSpendUsd / branches.length,
maxMs: parent.limits.maxMs } // time is shared, not divided
const results = await Promise.all(branches.map((b) =>
runLoop(newRun(b.question, narrow(parent.scope, b.restrict), each))
.then((state) => ({ branch: b.id, state, trace: [], spendUsd: 0 }))
.catch((error) => ({ branch: b.id, state: { kind: 'failed', error }, trace: [], spendUsd: 0 }))))
// Partial success is the normal case and must be reported as such.
const answered = results.filter((r) => r.state.kind === 'answered')
const failed = results.filter((r) => r.state.kind !== 'answered')
return { answered, failed, partial: failed.length > 0 && answered.length > 0 }
}Dividing spend and sharing time is the right split: two branches running concurrently take the wall-clock time of the slower one, but they each spend money.
Reporting partial branch failure
// ⚠️ A branch that failed must appear in the answer. The alternative is
// a comparison that silently compares one thing.
if (outcome.partial) {
answer.caveats.push(
`I could not check ${outcome.failed.map((f) => f.branch).join(' and ')}, ` +
`so this comparison covers only ${outcome.answered.map((a) => a.branch).join(' and ')}.`)
}Henderson Industrial is on Net 45 with a 12% volume discount [1][2]. ⚠️ I could not retrieve Acme Fabrication's terms — the contract system did not respond — so I cannot compare them.
That is a worse answer than the comparison and a much better one than a comparison of one account presented as a comparison of two.
Depth: one level
// ⚠️ A subagent may not spawn subagents. The limit is enforced, not a
// convention, because nothing about the second level is bounded by
// anything the first level can see.
export function newRun(question: string, scope: Scope, limits: Limits, opts: RunOptions = {}) {
const depth = (opts.parentDepth ?? -1) + 1
if (depth > 1) throw new Error('subagent depth limit: subagents may not spawn subagents')
return { ...base, depth }
}Atlas has never needed a second level, and the one time a design called for it the real requirement was a fixed workflow at the inner level.
The failure mode: the subagent that re-retrieved everything
Atlas's comparison question spawns two branches. Each branch runs its own loop, and each loop starts by retrieving — because that is what a run does.
Both branches retrieve the same policy documents. The shared context is fetched twice, embedded as a query twice, reranked twice, and sent to the model twice. Token use went up 40% against the single-agent version, which is the opposite of the table above.
The measurement that produced the favourable numbers was taken after this was fixed.
// Shared context is retrieved once by the parent and passed down.
// ⚠️ Passed as DATA, not re-fetched, and the branch's scope still applies
// to anything it fetches itself.
const shared = await retrieveAndRerank(parent.question, { scope: parent.scope, limit: 6 })
const results = await Promise.all(branches.map((b) =>
runLoop(newRun(b.question, narrow(parent.scope, b.restrict), each, {
seedContext: shared.results.filter((c) => isPermitted(c, narrowedScope(b))),
parentDepth: parent.depth,
}))))The filter on the seed is not optional: a parent with wider scope than a branch must not hand down chunks the branch's scope would not have retrieved, or narrowing is decorative.
Branches that each start from scratch duplicate the expensive part. Retrieve the shared context once in the parent, filter it to each branch's scope, and pass it down.
Key points
- Four common reasons for splitting are better solved by trimming context, filtering tools, pruning the prompt, or routing by model tier.
- Three cases where it is right: an isolated context that must not leak, parallel branches of a decomposed question, and a different trust level.
- Most useful subagents are isolated single calls with no tools, not nested loops.
- A subagent inherits the caller's scope, narrowed. Enforce that
narrow()can only intersect, and test it. - Splitting bought Atlas 42% lower latency and 29% lower cost, and cost four new failure modes and an interleaved trace.
- Divide the spend budget between branches; share the time budget.
- Report partial branch failure in the answer. A comparison of one thing presented as two is the failure.
- Enforce a depth limit of one. A design needing two levels usually wants a fixed workflow at the inner one.
- Retrieve shared context once in the parent and filter it per branch. Branches retrieving independently made the split more expensive than not splitting.