# Routing sub-agents to models without risking the run

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/routing-sub-agents-to-models-without-risking-the-run/
Published: 2026-11-03

A router with a bounded judge, a fallback that keeps the child task moving, layered settings, and a record of why each sub-agent got its model and effort.

In August 2026, our platform's sub-agents ran on separate lanes of the same durable session tree. They shared the parent's system prompt and most of its tools, but received their own briefs and model choices. The model came from a router, which classified the brief as easy, medium or hard and picked a model and an effort to match. That put a small classifier directly in front of child tasks, where it could hang, fail or return nonsense at the moment a real piece of work was waiting to start.

Routing is an optimization, and I built this router around a rule carried over from two earlier routers on the platform: routing can cost cents; it must never cost a run. Keeping that promise took a deadline and a fallback that did not depend on the judge, plus a record of why each child received the model it did.

## A judge that knows nothing about models

The classifier was called the judge, and by default it was a small model at low effort. Its prompt described the three classes, gave examples and broke ties by telling it to pick the higher one, but it never saw a model name. A separate ladder mapped each class to a model and an effort, ordered from cheapest to most capable, so the rubric described the work and the ladder described the spending. Model identifiers and defaults changed frequently in those months, and with this split a new model meant a change to the ladder and not a rewrite of the judge's instructions. The default ladder at the time used one model at low, medium and extra-high effort, which means the judge was mostly deciding how hard that model should think.

Reading the verdict was deliberately forgiving. The parser took JSON if it got JSON, accepted a bare class name if it didn't and returned null for anything else, and a null was treated like every other failed judgment: a timeout, a transport error or a malformed verdict all sent the child to the ladder's default arm, which was medium. That default was a real trade. A hard task whose judge timed out ran at medium effort and might do worse for it. The middle arm limited the size of the mistake in both directions, since a failed judgment could neither hand difficult work to the cheapest arm nor spend the most capable arm on something trivial, and the child went ahead without waiting on a component whose only job was to choose.

## A deadline that doesn't depend on the client

An abort signal is a request, and a model client that ignores it leaves the caller holding a promise that may never settle. So the router didn't rely on the client's cooperation. It raced the judge's promise against its own timer and against cancellation, aborted the controller when the deadline passed and cleaned up in a `finally` block. The race ended the wait without stopping the losing operation, and the code also attached explicit rejection handlers to all three promises. The default deadline was eight seconds.

The test for this was a hanging judge in the most literal sense software offers: a judge that never answered, a 20 millisecond timeout, and assertions that a decision came back within two seconds with fallback as its source and a note that it timed out. A judge that threw took the same path and carried its error message along. With routing disabled the judge wasn't called at all, and the decision said so.

## Every path leaves the same record

A parent could skip the judge by naming a class when it spawned the child. The router honored that and recorded it as an override, with the rationale that the caller pinned the complexity, so deliberate escalation stayed possible and stayed visible. The spawn tool described the parameter as "Rarely needed; never the same value on every spawn," and that second clause was aimed squarely at its reader, which was a model. A parent that pinned the same class every time would have switched routing off without anyone having decided to.

Whichever path produced it, the decision was a record of the class, the arm, the source, a rationale and the latency. The spawn tool attached it to its result and to the child's record, and the agent listing showed each child on one line as complexity, model, effort and source. Consider what that did for a child running on the medium arm. Without the source, "the judge thought this was a medium task" and "the judge timed out" look the same, and they are very different facts about the system. A child's model calls were also reported under a sub-agent usage role, so the cost of delegated work didn't blur into the parent's.

## Where the ladder comes from

All of that explains how a brief reached an arm. It doesn't explain why the hard arm meant what it meant, and that answer lived in a different mechanism. The host built the ladder from settings, whether routing was enabled and what effort a hard task received among them, and kept the defaults where nothing was set. Those settings came from a cascade of user, project and thread scopes. The two mechanisms met at that point and stayed separate otherwise: the resolver decided what an arm was and the judge decided which arm a brief got.

Each agent's manifest declared its fields with their types, enums and ranges. The effort for hard tasks, for instance, was an enum of low, medium, high, xhigh and max, defaulting to xhigh. Writes to settings defined this way were checked against that schema, so an unknown field or an invalid value was refused with a message explaining why. Resolution started from the manifest default, applied user, project and thread in that order, skipped any value that failed validation and filled in a parallel map recording which layer supplied each field. Suppose, as an illustration, that the user has chosen high, the project max, and the thread somehow holds the string "extreme":

| Layer | Stored value | Effective value after this layer |
|---|---|---|
| Manifest default | xhigh | xhigh, from the default |
| User | high | high, from the user |
| Project | max | max, from the project |
| Thread | extreme | max, from the project, because the thread's value is skipped |

A write would have refused "extreme" outright, but the resolver didn't assume that everything it found had arrived through the current schema, and one bad value fell through without corrupting the result. Resolution happened in the gateway at the start of each turn, so a change applied from the next turn, and the resolved values and their sources were stamped onto that turn's metadata.

An interface has a different job, which is keeping the stored value and the effective value apart where a person can see the difference. The client read and wrote one scope at a time and loaded the inherited layers along that scope's chain. It kept overrides separate from the values assembled for display, but its display coercion did not apply the gateway's validation: an invalid enum such as "extreme" could remain in that display value even while the turn used max. The turn's recorded settings remained the authoritative result of the cascade.

The case that shows why the distinction matters is a write that sets a field to the value it would inherit anyway. Such a write deleted the override and reported that nothing was stored. If it had stored the value instead, someone who "set" a thread to the project's current effort would have silently pinned that thread, and the next change at project level would have stopped reaching it for no reason visible on screen.

## Following the choice

Follow one child from end to end and the two records meet. In the example above, the turn's settings say the hard arm's effort came from the project. The routing decision says this brief reached the hard arm because the judge classed it that way after so many milliseconds, or because the parent pinned it.

A fallback that fires correctly keeps the task moving; deciding whether medium was right for it means looking at the task's outcome. I still think this is the right order to build things in. A router needs to survive a failed judgment and make its choices explainable before we can sensibly evaluate the policy behind them. Those records let us separate the judge's classification from a timeout, a parent override or a change to the ladder.
