Skip to content

15 min read

Who may hand to whom is a decision, not a default

On this page

A topology, for a swarm, is the set of edges that say which agent may hand a conversation to which. It is a property every such system has, whether or not anyone chose it, because an agent can only transfer to a destination that appears in its own toolset, and something put those tools there. This chapter is about treating that set as something to design: what it costs when it is left at its default, what shape it takes when it is drawn deliberately, and what a declared graph can and cannot do about a routing decision going wrong.

The wiring that produced the topology of one five-agent support swarm is four lines long and was written in the first afternoon of the project.

python
AGENTS = [triage, refunds, billing, orders, notifications]

for a in AGENTS:
    a.tools += [make_transfer_tool(b) for b in AGENTS if b is not a]

Nobody decided that the notifications agent should be able to hand a conversation to the billing agent. The loop decided it, because writing the loop was easier than writing out twenty edges, and twenty is what five agents give you when each can reach all the others. That count is worth deriving rather than accepting, because it is what makes the default expensive: each of the agents gets one transfer tool per peer, and it has one fewer peer than there are agents, so the number of directed edges is the agent count multiplied by one less than itself. At five agents that is twenty, and each agent added to the list adds a tool to every prompt already in the system as well as bringing its own.

The cost of those edges is not hypothetical: each agent's prompt now carries four handoff tool schemas at roughly 195 tokens each, so 780 tokens ride along on every model call the system makes, and any of the twenty edges can be taken at any hop by an agent that reads its own instructions loosely.

Three dials nobody turned

The first is which peers an agent's transfer tools expose at all. This is not a matter of prompt discipline, because a tool that is absent from the schema cannot be called; the model has no way to name it. Removing transfer_to_billing from the notifications agent removes a class of routing error completely, at the cost of making a legitimate path impossible, and that trade is exactly the thing worth spending an hour on.

The distinction underneath that first dial is the one to carry forward. An instruction in a prompt is a request the model may or may not honour, evaluated by the model. An absent tool schema is a name the model has never been shown, so there is no string it can emit that the runtime will resolve to that destination. Those are different kinds of control, and only the second one holds when the model is having a bad turn.

The second is whether control may come back to an agent that has already held the conversation. Returns are ordinary — refunds sends a customer back to triage when they raise something unrelated, and triage sends them on to orders — and they are also the mechanism by which a run can circulate forever. Whatever position you take, it should be a position rather than an accident.

The third is what the sender is shown about a destination before it commits. The routing decision is made from the tool schema, which in most codebases means a single line of description written by whoever added the agent. """Transfer to the billing agent.""" tells the model nothing about when billing is the right answer, so the decision is made on the agent's name. Descriptions that state the trigger condition and the exclusion do measurably better, and they are the cheapest change in this chapter.

Why an exclusion helps more than a longer description of the destination is the causal step here. A routing decision is a choice among the available tools, so what the model needs is not a fuller account of one destination but something that separates it from its neighbours. Two agents whose descriptions are both accurate and both silent about each other leave the model to break the tie on whatever else is in front of it, which is usually the name. A sentence naming the case that belongs to the other agent draws the boundary the choice actually turns on.

python
def make_transfer_tool(target: Agent):
    def transfer() -> Agent:
        return target
    transfer.__name__ = f"transfer_to_{target.name}"
    transfer.__doc__ = target.handoff_description   # written per agent, not generated
    return transfer

billing.handoff_description = """Transfer here for questions about what the
customer was charged, invoice lines, tax, plan changes and duplicate charges.
Do NOT transfer here to issue a refund on a specific order — that is refunds.
Do NOT transfer here for payment method failures at checkout — that is orders."""

Rewriting the five descriptions in that form, with the exclusions spelled out, moved destination accuracy on a 200-conversation labelled set — conversations for which somebody has written down, hop by hop, which agent control should have gone to — from 84% to 93% and cost about 90 extra tokens per description.

The graph you would draw on purpose

Set against the twenty-edge default, the graph this workload actually needs is smaller and has a shape.

Flowchart: triage → refunds; triage → billing; triage → orders; refunds → notifications; billing → notifications; orders → notifications; refunds → triage; billing → triage; orders → triage; notifications → resolve; refunds → resolve; billing → resolve; orders → resolve; triage → resolve.

Triage fans out and every specialist can return to it, which is the second-issue path from chapter 1. Notifications is a sink that no specialist transfers out of except to finish, because a system that lets the notifications agent choose a new specialist is a system where "send the confirmation" can turn into a fresh investigation. Every node has an edge to resolve, which is the terminal transfer, and that property is the one to check first when you draw one of these: if any agent can be reached from which the only exits are other agents, a run that arrives there can only keep moving.

Declaring the graph is worth doing outside the prompt as well as inside it, because prompts are advisory and the runtime is not. The table below is an allowlist, which is a list of the pairs that are permitted with everything absent from it refused, so an edge nobody wrote down does not exist at run time whatever a model decides to call.

python
ALLOWED = {
    "triage":        {"refunds", "billing", "orders", "resolve"},
    "refunds":       {"notifications", "triage", "resolve"},
    "billing":       {"notifications", "triage", "resolve"},
    "orders":        {"notifications", "triage", "resolve"},
    "notifications": {"resolve"},
}

def apply_transfer(state: RunState, to_agent: str) -> Agent | ToolError:
    if to_agent not in ALLOWED[state.current_agent]:
        return ToolError(
            f"{state.current_agent} cannot transfer to {to_agent}. "
            f"Available: {sorted(ALLOWED[state.current_agent])}. "
            f"If none fit, call resolve(status='partial') with the gaps named."
        )
    return AGENTS_BY_NAME[to_agent]

Returning a tool error rather than raising matters, because the sender is still running and can recover from an error the way it recovers from any failed tool call, and the error text is what tells it what to do instead. In the six weeks after this went in, the guard fired 211 times on about 34,000 runs, and 190 of those were one edge: the notifications agent trying to transfer to orders when a delivery date looked wrong. That is a routing instinct the graph disagrees with, and the interesting part is that it was invisible before, because previously the transfer simply succeeded.

That last sentence is the whole argument for enforcing the graph in code rather than in a prompt, and it is worth taking a step at a time. Before the guard, an edge the design did not want was indistinguishable from an edge the design did want, because both produced a successful transfer and a green agent. Adding the guard did not change how often the notifications agent wanted to reach orders; it changed a silent success into a counted refusal. The number 190 did not appear because the behaviour started, it appeared because the behaviour became observable, and that is the general reason to prefer a mechanism that can say no over an instruction that cannot.

Most of the twenty edges were never load-bearing

The graph above was drawn from an argument in a room, which is a reasonable way to start and a bad way to finish. The transfer tool calls were already being logged with a destination, so the same question could be asked of six weeks of traffic: of the twenty edges the loop created, which ones carry anything.

text
$ transfers --since 6w --group-by edge --total 61,412
triage        -> orders           14,902   24.3%
triage        -> refunds          11,338   18.5%
triage        -> billing           9,004   14.7%
refunds       -> notifications     8,771   14.3%
orders        -> notifications     6,190   10.1%
billing       -> notifications     4,455    7.3%
refunds       -> triage            3,102    5.1%
orders        -> triage            1,988    3.2%
billing       -> triage            1,441    2.3%
notifications -> orders              128    0.2%
refunds       -> billing              43    0.1%
billing       -> orders               22    0.0%
notifications -> billing               6    0.0%
orders        -> refunds               5    0.0%
... 6 edges with 0 transfers

The listing is read most usefully as a gap rather than as a ranking. The nine edges above the line are the graph somebody would draw on purpose, and they carry 99.8% of the traffic. The eleven below it carry 204 transfers in six weeks, and on the labelled 200-conversation set those rare edges account for 22% of the wrong destinations, which is the ratio that makes the decision easy: they are almost never used and when they are used they are usually a mistake.

Two different populations are being combined in that sentence, and the combination is what makes it persuasive, so it is worth stating rather than gliding over. The 204 comes from six weeks of production traffic, where nobody has labelled anything and the only fact available is which edge was taken. The 22% comes from the two hundred conversations somebody sat down and annotated hop by hop. Neither figure alone decides the question — a rare edge might be rare and correct, a wrong destination might sit on a busy edge — and putting them together is a claim that the labelled set is representative of the traffic. That claim is probably true here and it is an assumption rather than a measurement, and it is the assumption a reviewer should ask about before an edge is deleted.

Deleting them also takes the mean number of handoff schemas per agent from four to 2.4, saving about 312 input tokens on every model call, which at roughly 33,600 calls a day and $5 per million input tokens is $52 a day. That last figure is one multiplication and one division from the two before it: the tokens saved per call, multiplied by the calls in a day, priced at five dollars for every million tokens. That saving is incidental to the reason for doing it, and it is the number that got the change scheduled.

The tightened graph is not free either, and the cost lands on exactly the conversations chapter 1 identified as the hard ones. With refunds → billing deleted, a customer who raises an invoice question mid-refund now goes refunds → triage → billing, which is one extra hop at about $0.13. That happens on the 15% of conversations that raise a second issue, so it is around $0.02 per conversation averaged over the workload, or $47 a day against the $52 saved. The two figures roughly cancel, which means the argument for the smaller graph rests on the 22% of mis-routes rather than on the bill, and saying so honestly is what stops the change being sold on a saving that does not exist.

Two figures cancelling is a more useful result than either of them winning, and the reason is structural rather than lucky. Both sides of that ledger are the same quantity — tokens on a model call — moved around: one side removes schema tokens from every call, the other adds a whole extra call. Whenever the two terms are the same kind of thing and are within a factor of each other, the sign of the difference depends on estimates nobody has to a useful precision, so a decision resting on the sign is a decision resting on noise. The mis-route rate is a different quantity, measured differently, and that is what makes it able to carry the argument.

Nothing tells you about the agent nobody can reach

A declared graph is a data structure, so the properties that matter can be asserted at startup rather than discovered in production.

python
def check_graph(allowed: dict[str, set[str]], entry="triage", terminal="resolve"):
    reachable, frontier = {entry}, [entry]
    while frontier:
        for nxt in allowed.get(frontier.pop(), set()):
            if nxt not in reachable and nxt != terminal:
                reachable.add(nxt); frontier.append(nxt)

    problems = []
    for agent in allowed:
        if agent not in reachable:
            problems.append(f"{agent} is unreachable from {entry}")
        if not can_reach(allowed, agent, terminal):
            problems.append(f"{agent} cannot reach {terminal}: a run arriving "
                            f"there can only keep transferring")
        if not allowed[agent]:
            problems.append(f"{agent} has no outgoing edges")
    return problems

Both checks are cheap and both catch a class of defect that no test of an individual agent can. The reason no per-agent test can reach them is that reachability is not a property of an agent at all. It is a property of a path, and a path is made of edges belonging to other agents; a perfectly configured agent with correct instructions, working tools and a sensible allowlist entry is unreachable exactly when some other agent's entry omits it. Testing each agent in isolation cannot see an omission that lives in a different agent's dictionary.

The second check is the property named earlier in this chapter — every node must have a path to resolve — and it is worth enforcing rather than trusting, since the natural way an allowlist acquires a trap is that somebody removes one edge to prevent a specific mis-route and the destination that edge led to was the only exit.

What happens when there is nowhere sensible to go

A graph that includes resolve makes an unfinishable run expressible, and expressible is not the same as handled. Consider the state the last agent is actually in: it has produced something partial, it is not confident, and none of its available destinations look right. Its instructions tell it to help the customer, and every option in its toolset is a transfer, so the transfer it takes will be the least-bad edge rather than a good one.

What that agent needs in front of it is the run's termination conditions and its remaining budget, carried in the payload from chapter 2, so that "this run cannot be finished" is a state it can recognise rather than infer. With stop_conditions and budget in the prompt, the terminal move is a comparison — none of the stop conditions are met, two hops remain, the outstanding item requires a supplier response that will take a day — and the correct action is to call resolve with the status set to partial and the gaps named explicitly, so that the incomplete answer arrives labelled as incomplete rather than as an answer.

python
resolve(
    status="partial",
    delivered=["Refund of £68.40 issued to the original card, ref RF-90412."],
    gaps=["Customer also asked why invoice INV-8871 shows two line items; "
          "not investigated. Billing agent has the order id and the invoice id."],
)

The rule that looks like it bounds the run

A proposal that comes up whenever someone first sees a swarm loop is to forbid transferring to any agent that has already seen the conversation. It has the shape of a proof — with five agents and no revisits, a run can take at most five hops — and it fails in both directions.

It forbids returns that are the normal way this workload gets handled. A customer mid-refund who says "and while you're there, my last invoice is wrong" needs refunds → triage → billing, and under the rule triage is already burnt, so refunds must either route to billing itself, which is the accidental-delegation failure — a specialist taking on the routing job that was never handed to it — that the graph was drawn to prevent, or drop the second issue. On the 15% multi-issue slice from chapter 1, a no-revisit rule turned 41% of those conversations into partial answers or wrong-agent handoffs when it was trialled behind a flag for two days.

It also does not bound what people think it bounds, and this is the belief worth naming, because it is a defence that feels like a proof. The rule holds one quantity constant — the number of distinct agents a conversation may visit — while the quantity that actually hurts, the money and time a run consumes, varies freely underneath it. "Has seen the conversation" has to be implemented against something, and whatever it is implemented against is what the system routes around. Keyed on agent name within one conversation id, the rule leaves the long way round intact: triage → billing → orders → notifications is four hops with no repeats, each hop a full-context inference costing about $0.13, and nothing about the rule says those hops have to be useful. Keyed the same way, it also breaks the moment someone adds the context-compaction handoff that every long-running swarm eventually grows, where the conversation is summarised and re-keyed to control prompt size, because the new conversation id has a clean visited set and the cycle resumes from zero. And in neither case does it prevent two never-visited agents from doing the same work twice.

forbid revisitsforbid immediate back-transfer (A→B→A)declared edge graph + run budget
blocks legitimate returnsyes, the multi-issue pathrarelyno, returns are edges you chose
bounds hopsonly per conversation idnoyes, by the budget
bounds spendnonoyes
survives context compactionno, visited set resetsyesyes
prevents duplicated side effectsnonono, needs a claims ledger
tells you why a run stoppednonoyes, the trace names the edge or the budget

The row to read across is the last but one, because all three columns say no. A routing restriction of any kind constrains where control may go and says nothing about what happens when it gets there, so no arrangement of edges prevents a second agent repeating a side effect a first agent already caused. The claims ledger named in that row is a record of the external side effects a run has already caused, kept so that a second agent cannot repeat one, and it is built in chapter 6.

The rule is a routing restriction standing in for a run-level budget. Routing restrictions answer the question "where may control go", and the $38 conversation is a question about "how much may this run consume before it stops", which no arrangement of edges can answer, because a graph with legal cycles and a graph with illegal ones both permit runs of unbounded length as long as something keeps deciding to transfer.

The graph is a containment device and not a routing engine. It decides which destinations exist, which is a question with a checkable answer, and it decides nothing about which of the surviving destinations is the right one, which is a judgement made by a model from a tool schema. Removing transfer_to_billing from notifications makes one wrong destination unreachable and says nothing about whether the remaining choices get made well, and the descriptions that improved accuracy from 84% to 93% left 14 conversations in 200 going somewhere defensible and wrong.

Those are the ones that produce a billing agent answering a shipping question, and finding out how control arrived there is not something the graph can tell you after the fact — it can only tell you the path was legal.

Swarm / handoff — read the current version at https://daviddominguez.dev/learn/en/swarm-handoff/who-may-hand-to-whom-is-a-decision-not-a-default

Back to top