Give a language model a slightly better prompt and you might squeeze out a few percent. Give it a second copy of itself to delegate to, and Anthropic measured a 90.2% jump on research tasks over the same model working alone. Here's the part that should bother you: the improvement had almost nothing to do with the models getting smarter. The lead agent and the workers ran the same family of models. What changed was how the work got divided.
That's not a machine-learning result. It's a domain-modeling result. And it points at something most teams building agent systems get backwards: the hard parts of multi-agent orchestration aren't the prompts or the model choice. They're boundaries, invariants, and failure handling, which is to say they are exactly the problems domain-driven design was built for. This is the same lens the CQRS and credentialing pieces used on ordinary backends, pointed at the agent stack.
The Pattern, Stated Plainly
The orchestrator-subagent pattern (sometimes orchestrator-worker) is simple to describe. A lead agent takes a goal, plans an approach, and spins up a handful of subagents, typically three to five, each with a narrow mandate. The subagents run in parallel. When they finish, the lead agent synthesizes their results into an answer, often with a separate citation or verification pass at the end.
The crucial detail, the one that makes the whole thing work and also makes it hard: each subagent runs in its own context window, with its own tools and its own exploration trajectory. The orchestrator hands out an assignment and gets back a result. It never sees the subagent's intermediate reasoning, the dead ends, the forty tool calls it took to get there. Only the finished product comes back.
If that arrangement sounds familiar, it should. You have already been modeling systems where one component gives another an instruction and receives only a result, with the internals sealed off. That is an aggregate boundary, and treating it as one is the whole game.
The Context Window Is an Aggregate Boundary
In DDD, an aggregate is a cluster of objects you treat as a single unit, with a root that is the only legal way in. You don't reach into an aggregate and read its internals. You send its root a message and it hands you back a result. The internal reasoning stays private.
A subagent's context window is exactly this boundary, enforced by the runtime instead of by discipline. The orchestrator physically cannot see inside a subagent's window. It gets the published result and nothing else. Which means the thing coming back across that boundary deserves to be modeled as a proper value object, not a bag of loose strings:
// The result is the ONLY thing that crosses the boundary.
// Model it as a value object with provenance, not a raw string.
class SubagentResult {
private constructor(
readonly taskId: string,
readonly findings: string,
readonly sources: Source[],
readonly tokensSpent: number,
readonly completedAt: Date,
) {}
static fromCompletion(raw: SubagentCompletion): SubagentResult {
if (raw.sources.length === 0) {
// A "finding" with no sources is not a result. It's a claim.
throw new DomainError(`Subagent ${raw.taskId} returned findings with no sources`);
}
return new SubagentResult(
raw.taskId, raw.findings, raw.sources, raw.tokensSpent, raw.completedAt,
);
}
}
The same move as the credentialing article's VerifiedCredential: a private constructor and a single factory that enforces an invariant at the boundary. There, a credential couldn't exist without primary-source verification. Here, a result can't exist without sources. The orchestrator's synthesis step then gets to assume every result it holds already cleared that bar, instead of re-checking each one defensively.
The Orchestration Is the Aggregate Root
So what owns the invariants? Not the subagents, they're isolated by design and don't know about each other. The orchestration run itself is the aggregate root. It's the thing that holds the goal, tracks which tasks are outstanding, and enforces the one rule that matters most.
That rule is a guard you have seen before: don't synthesize until every delegated task has resolved. In the credentialing domain it was "you can't send a file to committee review with a hole in it." Same shape here:
class Orchestration {
private status: OrchestrationStatus = OrchestrationStatus.Planning;
private tasks: Map<string, TaskState> = new Map();
private results: SubagentResult[] = [];
synthesize(): Answer {
const unresolved = [...this.tasks.values()].filter((t) => !t.isResolved());
if (unresolved.length > 0) {
throw new DomainError(
`Cannot synthesize with ${unresolved.length} task(s) still outstanding`,
);
}
this.status = OrchestrationStatus.Synthesizing;
return Answer.from(this.results);
}
}
Nothing about this is AI-specific. It's an aggregate refusing to enter an invalid state. The fact that the "tasks" happen to be language models exploring the web is an implementation detail the invariant doesn't care about.
The Bug Is a Missing Invariant
Here's a real failure the Anthropic team reported: the orchestrator would sometimes get over-enthusiastic and spawn fifty subagents for a question that needed two. Others would spin in loops chasing sources that didn't exist.
It's tempting to read that as the model being flaky. It isn't, or at least that's not the useful reading. Spawning fifty workers is what happens when delegation is an unbounded loop with no invariant guarding it. The domain has a rule, fan-out has a sane ceiling, and if that rule doesn't live anywhere in the model, the model has no way to honor it. So you put it where rules go, in a policy the aggregate consults, not a magic number buried in a prompt:
class FanOutPolicy {
constructor(private readonly maxSubagents: number) {}
admit(current: number, requested: number): number {
// Never exceed the ceiling. Return how many we can actually spawn.
return Math.max(0, Math.min(requested, this.maxSubagents - current));
}
}
This reframing matters because of where agent systems actually break. One study of failures across seven multi-agent frameworks, AG2 (formerly AutoGen), MetaGPT, and ChatDev among them, found that coordination failures, what the authors call inter-agent misalignment, accounted for 36.94% of everything that went wrong. Fold in the 41.77% they pinned on specification and system design, and more than three-quarters of failures trace to design and coordination, not to the model being dumb. The boundaries between agents are where the bugs live. That's a domain-design problem with a domain-design fix.
Subagent Results Are External Events, Not Return Values
Now the part that makes this genuinely harder than a credentialing case, and where you have to break the analogy.
In an ordinary domain, transitions are deterministic. Approve a clean file and it becomes approved, every time. A subagent is not deterministic. Hand the same mandate to the same model twice and you can get two different trajectories, two different token costs, and occasionally a worker that never comes back at all. You cannot model a subagent call as a function that returns a value. You have to model it as something that might arrive, might fail, or might hang, arriving from outside your control.
Which, again, is a shape the credentialing model already had: the sanction that could land at any time and knock an approved provider into suspension. An outside event the aggregate has to be ready for. Delegation is the same. Every subagent is a potential SubagentFailed or SubagentTimedOut waiting to happen, and the orchestration has to stay valid when it does:
onSubagentTimedOut(taskId: string): DomainEvent[] {
const task = this.tasks.get(taskId);
if (!task || task.isResolved()) return [];
// A timeout resolves the task as failed, not as pending forever.
task.markFailed("timeout");
// The whole run is degraded, but it is NOT dead. Partial answers beat none.
return [new SubagentFailed(this.id, taskId, "timeout")];
}
Marking the task failed is what keeps synthesize()'s guard honest. A timed-out worker is a resolved task, so the run can still complete on the results it did get, degraded but alive, rather than blocking forever on a subagent that is never coming back. The rule "don't wait on the dead" is a domain rule, not a networking detail.
Cost Is a Domain Rule
There's one invariant this domain has that most don't, and it's expensive to ignore. Multi-agent runs are costly. Anthropic put the figure at roughly fifteen times the tokens of a normal chat, and found that token usage alone explained about 80% of the performance variance in one of their benchmarks. Spend is not a side effect here. It's close to the primary lever.
That makes a token budget a first-class invariant, the same way the 36-month clock was in the credentialing model. The aggregate owns a budget and refuses to over-delegate past it, so a single runaway question can't quietly burn the month's spend:
delegate(tasks: TaskMandate[], budget: TokenBudget, policy: FanOutPolicy): TaskMandate[] {
const slots = policy.admit(this.tasks.size, tasks.length);
const affordable = budget.remaining() > 0 ? slots : 0;
if (affordable === 0) {
throw new DomainError("Fan-out blocked: budget exhausted or ceiling reached");
}
return tasks.slice(0, affordable);
}
If you have ever wired FinOps guardrails into a platform, this is the same idea moved one layer up: enforce the budget at the moment of spend, not in a dashboard you read afterward.
When the Boundary Is Wrong
The honest limit, because this pattern is oversold right now. The isolation that makes orchestration powerful is also its hard constraint. Subagents can't see each other's reasoning, only the orchestrator can, and only the finished results at that. That's a fantastic fit for breadth-first work: research a topic from five angles at once, explore independent branches, gather in parallel. It's a terrible fit for tightly coupled work where each step depends on the intermediate state of the last, because the boundary you're relying on is precisely the thing blocking that state from flowing.
In DDD terms, this is a diagnosis you already know how to make. If two aggregates constantly need to reach into each other's internals to do their jobs, they aren't two aggregates. You drew the boundary in the wrong place, and they should be one. Same rule here: if your subagents keep needing each other's half-finished thoughts, multi-agent isn't buying you anything, and the isolation is pure overhead on top of a 15x bill. Collapse it back into one agent with one context and move on.
Which is the quiet punchline. The interesting questions in agent engineering right now are not "which model" or "what prompt." They're "where does this boundary go," "what invariant holds this together," and "what happens when a piece of it fails." We have had good answers to those questions for twenty years. They just never had this particular costume on before.
Originally published at andriiboyko.com.


Top comments (0)