Skip to content

Delegation — how far a run may go, and what a child may hold

Multi-agent runs fail in two loud ways and one quiet one. They recurse until the budget is gone; two agents hand the same task back and forth forever; or — the quiet one — a sub-agent three levels down ends up with the allowlist from its own configuration rather than the narrowed scope of the caller it acts for. The third is a privilege escalation dressed as a default.

So the shape of a run is bounded and its scope only ever narrows:

scope = DelegationScope(tools=frozenset({"search", "summarise", "file_bug"}))
root = Delegation.root(run_id="run_1", tenant="acme", agent="supervisor", scope=scope)

researcher = root.to("researcher", tools={"search"})
researcher.path          # ('supervisor', 'researcher')
researcher.scope.tools   # frozenset({'search'})

A delegation comes from Delegation.root or from parent.to(...) and from nowhere else. The constructor raises ConfigurationError, because one built by hand would carry a scope nobody narrowed, a depth nobody counted and a ledger nobody shares.

The ceilings

DelegationLimits bounds three different things, and a run needs all three:

Default Bounds
max_depth 3 How many agents may sit between the root and the deepest worker.
max_fan_out 8 How many children one agent may hand work to.
max_delegations 24 How many delegations the whole run may make.

Depth and fan-out each bound one lineage. max_delegations bounds their product, which is where a shallow, very wide tree would otherwise escape both.

Limits narrow downward only. A child may declare tighter ceilings for itself and they apply; a child that declares roomier ones keeps its parent's, since a limit that can be raised from inside is not a limit.

An agent already on the current path may not be delegated to again — reason="cycle" — even when the depth ceiling would still permit it. Two agents alternating below the ceiling is a loop that terminates only by exhausting the budget. The same agent on two separate branches is not a cycle and is allowed.

What a refusal is

Every refusal happens before the child is created, so it costs nothing and spends nothing from the run's allowance.

try:
    parent.to("researcher", tools={"wire_transfer"})
except ScopeEscalationError as refused:
    refused.requested   # ('wire_transfer',)
    refused.path        # ('supervisor', 'researcher')

DelegationLimitError carries a reasondepth, fan_out, run, cycle or expired — and the path it happened on. ScopeEscalationError carries what was asked for that the parent did not hold.

Neither is retryable: the same call refused for the same reason refuses again, so a parent that retries it is looping rather than recovering. Both reach the parent as something it can reason about, because an agent that cannot delegate can often still answer.

What a child holds

to(agent) with no tools passes the parent's allowlist through unchanged. to(agent, tools={...}) intersects. Asking for anything the parent does not hold is ScopeEscalationError rather than a grant — refused whether or not the child's own configuration would permit it. mutations works the same way, for deployments that separate reading from writing.

A child asking for no tool at all is refused too: a delegation that could call nothing was a mistake at the call site, not a maximally safe setting.

The tenant is not a parameter of to() at all. A child inherits its parent's TenantContext, so crossing a tenant boundary by delegation is unrepresentable rather than merely checked.

Expiry

A scope may declare expires_at, read against the Clock passed to root. Delegating on an expired scope raises DelegationLimitError(reason="expired"). Time that cannot be read fails closed, and root refuses a scope that declares an expiry with no clock to read it against — an expiry nothing evaluates is a comment.

What a delegated run inherits

Delegation is the model of a call graph. What follows is what the run loop enforces when one run actually calls another, with runner.run(child, parent=parent_run.context).

A kit with two dispatch paths grows a control that covers one of them. Guardrails covering tool calls but not delegation leave the cheapest bypass in the system open: hand the work to a sub-agent that declared no guard. So a run states what it was allowed to do, and a run below it inherits that rather than its own configuration.

parent = await runner.run(supervisor, "plan the work", tenant="acme")
parent.grant.tools                     # ('search',)
parent.grant.guardrails                # ('no_pii', 'no_prompt_leak')

child = await runner.run(researcher, "sub-task", tenant="acme", parent=parent.context)
child.grant.guardrails                 # ('no_pii', 'no_prompt_leak') — it declared none
  • Guards are inherited, in the caller's order, followed by any of the child's own that were not already there. A child cannot drop one, and a guard named at both levels is asked once. A guard the child's runner was never given is a ConfigurationError at the boundary rather than a skipped check.
  • Reach only narrows. A tool the caller did not hold is ScopeEscalationError, recorded as SCOPE_REFUSED and terminal, before a model is called. It is refused rather than intersected away, because the difference is a wiring mistake and a silent intersection is how nobody finds out about it. This holds at every depth: a grandchild cannot recover what the level above it gave up.
  • Approval is inherited. A tool a human had to clear at the top is not cleared by being called one level down.
  • Budget is shared, not reset. A parent passes bounds.budget.child(), so a delegation spends the caller's remaining allowance.

A RunContext built by hand outside the loop carries no grant and narrows nothing: the absence of a record is not a claim that the caller held nothing. Every context the loop produces carries one.

What comes back

messages.append(Message(role="user", content=[TextPart(text=handed_back(child))]))

A sub-agent's answer is model output that read whatever the sub-agent read. Pasted into the caller's conversation bare, it is an instruction channel for whatever wrote it, so it crosses in the same <untrusted-data> envelope a tool result crosses in.

A child a guard stopped hands back the guard and its code rather than an empty string, so a refusal inside a delegation reaches the caller as a refusal it can reason about rather than as an unexplained silence.

Handing work to a roster

Delegation says what a child may hold; Supervisor is the thing that actually hands the work over, so the narrowing above is not something each product rebuilds around its own runner.run call.

roster = Roster((
    Specialist(agent=researcher, capabilities=frozenset({"flights", "research"})),
    Specialist(agent=accountant, capabilities=frozenset({"refund"}),
               budget=BudgetLimits(max_input_tokens=2_000)),
))

supervisor = Supervisor(
    runner, roster,
    agent=planner,
    delegation=Delegation.root(run_id="run_1", tenant="acme", agent="planner", scope=scope),
    budget=run_budget,
    guardrails=guardrails,
)

result = await supervisor.delegate("find two refundable flights", needs={"flights"})
result.data                 # the answer, inside <untrusted-data>
result.answered             # False if the worker was stopped
supervisor.spent["researcher"]   # what that worker cost, under its own name

Routing is by declared capability. A Specialist declares what it can do, and delegate(needs=...) picks the narrowest worker that covers all of it — narrowest, so a generalist does not absorb work a specialist declared. A roster with nobody in it, or with nobody matching, is a DelegationError(reason="no_worker") rather than the supervisor quietly doing the work itself with its own wider access.

A worker holds the intersection of its own tools and what the supervisor holds under its scope. A worker sharing no tool with its caller never starts — DelegationError(reason="no_tools") — because a run that could call nothing would burn tokens to say so.

The allowance is a slice of the caller's ledger. budget.sliced(limits) is a tighter ceiling that is still deducted from the parent, so a worker cannot spend what the run does not have, and the run cannot spend more because it delegated. A worker that hits its slice ends in BUDGET_EXHAUSTED and comes back as a refusal the supervisor can read; it does not end the supervisor's run unless the call declared fatal=True. Spend is attributed by worker name whether the work finished or not, so a cancelled worker's partial cost is still on the ledger.

Cancellation flows down. supervisor.cancel(reason) cancels every worker in flight, including the provider call one is waiting on.

Two workers, one key. delegate(..., writes="itinerary") claims a key for the run. A second worker claiming the same key is refused with reason="conflict" rather than overwriting the first, since concurrent workers writing one key silently is the failure nobody sees until the answer is wrong.

Every hand-over lands on supervisor.events as DELEGATED or DELEGATION_REFUSED, with the worker's name, usage and reason — the record of what was handed to whom, for a run whose events span more than one agent.

Handing the conversation over

Delegation asks a worker a question and reads the answer. A handoff is the other shape: triage does not want an answer, it wants the conversation to belong to billing from here on. The shortcut is forwarding the transcript, which leaks context the target has no business seeing, pays for it by the token on every later turn, and lets the target infer permissions nobody granted it.

desk = HandoffDesk(
    runner,
    (
        Receiver(agent=billing, contract=HandoffContract(accepts=Ticket)),
        Receiver(queue=review_desk, name="review_desk",
                 contract=HandoffContract(accepts=Escalation)),
    ),
    agent=triage,
    delegation=Delegation.root(run_id="run_1", tenant="acme", agent="triage",
                               user="ada", scope=scope),
)

result = await desk.hand_off(
    "billing",
    reason="the customer disputes a charge",
    state=Ticket(account="ac_9", complaint="charged twice in March"),
    task="sort the double charge",
)
result.handoff.scope     # ('read_account', 'issue_credit') — the intersection
result.run.path          # ('triage', 'billing')

What crosses is the target's requirement, not the source's convenience. Each Receiver declares the Pydantic model it accepts. A payload that is not that model raises HandoffContractError(reason="contract") with the fields it got wrong, before the target is invoked — so a failed handoff leaves the conversation exactly where it was rather than half moved. Nothing else crosses: history and memory are forwarded only where the call passes them.

Identity is not a parameter. hand_off takes no tenant and no user; both come from the delegation, so a handoff into another tenant is unrepresentable rather than checked. The target holds the intersection of its own allowlist and the source's, and one sharing no tool with the source is refused with reason="no_tools".

A person is a receiver like any other. A Receiver wrapping a HandoffQueue is held to the same contract, so escalating to a human desk is the same call rather than a second path with its own rules. HandoffResult.queued says a person has it and run is None, because an empty run would claim they had already answered.

A run still in flight cannot be handed over. hand_off(..., after=run) refuses with reason="in_flight" unless the source run has settled: a handoff made with a tool call outstanding leaves that call owned by nobody.

The payload passes the guardrail chain — redaction included — before it reaches the target, the record, or telemetry. Every transfer and every refusal lands on desk.events as HANDED_OFF or HANDOFF_REFUSED, and each Handoff carries the run id and the path, so a conversation that has changed hands three times still reads as one trace.

Known limitations

Supervisor slices budgets for the workers it dispatches; Delegation itself still does not. A run that calls runner.run(child, parent=...) directly inherits the parent's resolved budget, so depth and delegation count are what bound spend on that path.

A write claim is held in the supervisor for the life of the supervisor, not in a store, so it coordinates workers under one supervisor and not two supervisors on one memory.

A handoff back to an agent already on the path is bounded by Delegation's cycle rule, not by the desk: a desk sees one hop at a time. Loop detection across a longer chain of handoffs is its own story.

DelegationScope.mutations is not part of RunGrant: an agent declares tools, not mutation classes, so there is nothing at the run boundary to narrow. Deployments that separate reading from writing express it through the tool allowlist.