Skip to main content

89 - Agent-as-Tool: Hierarchical Orchestration Instead of Delegation

If an agent becomes a tool, the caller keeps control. Orchestrator and sub-agents, parallelism, measured cost and a counter that stops runaway recursion.

What do you think about this article?

89 - Agent-as-Tool: Hierarchical Orchestration Instead of Delegation
Machine Learning Engineerfedericocalo.dev

At a Glance

If an agent becomes a tool, the caller keeps control. Orchestrator and sub-agents, parallelism, measured cost and a counter that stops runaway recursion.

Turning an agent into a tool means that whoever calls it does not give up control: it receives an answer, reads it and keeps reasoning. It is the opposite of the handoff in the previous article, where control passed and never came back. Agent-as-tool gives rise to a hierarchy, an orchestrator and sub-agents, which lets you compare opinions, run work in parallel and synthesise. The price is an input bill that grows fast and a new way of getting things wrong: the hierarchy that calls itself.

Version tested. openai-agents 0.24.0 on Python 3.14.4, on 2026-11-12, with the fake model described in the previous article (an agents.Model that replies from a script and records what it receives; the fake.py file is shown there). The listings prove the mechanics of the SDK, not the quality of a real model: the orchestrator's decisions are made by the script. The Agent.as_tool(...) method is in the tools documentation; pin the version and reread the changelog before upgrading.

The analogy: the consultant and the expert

In the handoff you were the customer passed from the switchboard to the refunds desk: from that point on you spoke to them. Here instead you are sitting in front of a consultant who, to answer your question, leaves the room, asks an expert for an opinion, comes back with the answer in hand and keeps talking to you. You have never met the expert. The consultant can even consult three of them, compare the opinions and tell you that two out of three advise against the operation.

The functional difference comes down to a single question: do you need the specialist's answer, or do you need the specialist to speak to the user? If you need the answer, to process it, compare it, insert it in a document, you want an agent as a tool. If you need it to carry on the conversation, you want a handoff.

The two forms, in a diagram

Handoff versus agent-as-tool: where control returns HANDOFF - control moves user agent A agent B B answers the user; A does not re-enter AGENT-AS-TOOL - control stays with the orchestrator user ORCHESTRATOR synthesises and answers legal sub-agent tax sub-agent technical sub-agent the three opinions return to the orchestrator, in parallel The user never talks to the sub-agents: they do not know they exist.
Handoff: one arrow that goes. Agent-as-tool: arrows that go and come back, and a point where you synthesise.

Four topologies, and when to use each

Handoff and agent-as-tool are the two bricks; with two bricks you build four recurring shapes. It pays to know them as shapes, because the question "which multi-agent architecture do I use?" often has one of these four as its answer, and choosing it in advance avoids finding out halfway through that the graph you drew cannot do what you need. The first three correspond to patterns described by Anthropic in Building effective agents: routing, orchestrator-workers and prompt chaining.

The star (routing). One triage agent, N specialists, handoffs only from the centre towards the periphery. It is the shape of the previous article. It serves when a request belongs to one domain and the specialist can finish it alone. Properties that make it preferable: no cycle possible by construction, a single point where decisions are made, and independent specialists that can be developed and tested separately. Limit: the specialist speaks directly to the user, so there is no single point where you can impose tone and output checks.

The hierarchy (orchestration). One orchestrator, N sub-agents exposed as tools. It is the shape of this article. It serves when several contributions have to be combined, or when a specialist's result must be processed before reaching the user; Anthropic recommends it when the sub-tasks cannot be predicted in advance. Properties: a single point of responsibility for the final answer, which is the place to put the output guardrail, the format, the voice. Limit: the multiplier on the input and the latency, if you do not parallelise.

The chain (pipeline of agents). Agent A produces, agent B transforms, agent C verifies. The graph is fixed and lives in your code: it is not an agentic pattern, it is a pipeline whose stages are agents. It serves when the sequence is known and each stage has a different mandate (extract, normalise, validate). Properties: predictable, testable stage by stage, no routing decision to get wrong. If you are thinking of an orchestration and you discover that the order of the calls is always the same, what you want is a chain.

The mesh (agents talking to each other). Every agent can hand off to every other. It is rarely needed. It has a strong intellectual appeal because it resembles a working group, and it has two costs that are always paid: cycles become possible, and the overall behaviour becomes hard to predict and to test. It is worth adopting only after verifying that the other three are not enough; the mesh that was "needed" is often a star with a better-written router.

A selection criterion to apply in this order: is the sequence known? Chain. Does a request belong to one domain only? Star. Do you need to combine contributions? Hierarchy. None of the three? Reconsider the problem before moving to the mesh.

For the industrial side of these choices (coordination patterns, scalability, observability of a multi-agent system in operation) the dedicated series goes deeper in multi-agent orchestration: architectures for production, and the frameworks that implement these topologies as primitives are covered in CrewAI: collaborative agent teams and AutoGen: multi-agent conversation. Here I stay on the mechanism and on the numbers.

Why orchestration entrusted to the model fails in silent ways

I write several times in the article "if the plan is known, write it in Python", and it is worth explaining why: the failures of model-driven orchestration do not look like errors. They produce plausible, incomplete results, which is the worst category. There are three concrete ways, described here as scenarios to anticipate in the design, not as statistics.

The sub-agent that is not consulted. The orchestrator has three reviewers and calls two, because it judged the third not relevant. The final answer reports two opinions, is well written, is coherent, and misses the opinion that would have blocked the clause. The crucial point is that there is no signal: no exception, no empty field, no warning. To notice it you have to compare the tools called with the expected ones, that is to do exactly the check that orchestration in code makes unnecessary.

The impressionistic aggregation. "The overall verdict is the harshest of the three" is a function. If you leave it to the model, it may apply it correctly or produce an average weighted by its impressions: two "needs changes" and one "unacceptable" become "needs changes", because the majority points in that direction. The result is defensible in words and wrong with respect to the rule you had set.

The context that is not propagated. The orchestrator calls legal_opinion("assess the clause above") because it has it in front of it. The sub-agent receives only that sentence and, not having the text, produces a generic opinion or invents a plausible clause and assesses it. The answer that comes back has the right shape, the quote field contains an invented quotation, and the orchestrator integrates it into the synthesis without batting an eye.

The three failures share a property: the final answer is well formed. A system that breaks with an exception warns you; a system that answers well a question it answered badly never warns you, and you find out when someone downstream checks by hand. Hence the operating rule: whenever a step of the orchestration is mandatory, it must be mandatory in the code. The prompt can ask; only the code can guarantee.

The code: three reviewers and a coordinator

Take a concrete case: a contract clause to be assessed under three different profiles. Each reviewer has its own instructions and an output_type (a Pydantic model, seen in the article on structured output); the orchestrator exposes them as tools with as_tool, queries them and produces a single verdict. In this trial the reviewers are scripts that return an already written opinion, and the orchestrator's script calls the three tools in the same turn, then synthesises.

import asyncio
import json
from typing import Literal
from pydantic import BaseModel, Field
from agents import Agent, Runner
from fake import FakeModel, tool_call, say

class Opinion(BaseModel):
    verdict: Literal["acceptable", "needs_changes", "unacceptable"]
    reason: str = Field(max_length=400)
    quote: str = Field(description="The exact part of the text you rely on")

CLAUSE = ("The supplier guarantees 100% availability of the service "
          "and undertakes to refund the entire annual fee "
          "for any interruption longer than one minute.")

seen = {"legal": [], "tax": [], "technical": []}

def opinion(verdict, reason):
    """The 'reviewer' of this trial: a script that returns an already written Opinion."""
    return say(json.dumps({"verdict": verdict, "reason": reason, "quote": CLAUSE[:40]}))

legal_reviewer = Agent(
    name="Legal reviewer",
    instructions=("You assess contract clauses from the civil-law point of view. "
                  "Always quote the portion of text you rely on. "
                  "If the clause does not concern your profile, verdict='acceptable' "
                  "and reason='outside my scope'."),
    output_type=Opinion,
    model=FakeModel([opinion("needs_changes", "A 100% guarantee is an obligation of result.")], seen["legal"]),
)
tax_reviewer = Agent(
    name="Tax reviewer",
    instructions="You assess tax and invoicing implications. Same rules.",
    output_type=Opinion,
    model=FakeModel([opinion("acceptable", "outside my scope")], seen["tax"]),
)
technical_reviewer = Agent(
    name="Technical reviewer",
    instructions="You assess the technical feasibility of the commitments made. Same rules.",
    output_type=Opinion,
    model=FakeModel([opinion("unacceptable", "No system guarantees 100%.")], seen["technical"]),
)

log = []
orchestrator = Agent(
    name="Review coordinator",
    instructions=(
        "You receive a clause. You must consult ALL THREE reviewers, always, "
        "even if you think one is not relevant: it is up to that reviewer to say so.\n"
        "Then synthesise: report the three verdicts, and the overall verdict is the "
        "harshest of the three. List the quotations.\n"
        "Do not express opinions of your own: only a synthesis of those received."
    ),
    tools=[
        legal_reviewer.as_tool(tool_name="legal_opinion",
            tool_description="Civil-law opinion on a clause. Pass the full text."),
        tax_reviewer.as_tool(tool_name="tax_opinion",
            tool_description="Tax opinion on a clause. Pass the full text."),
        technical_reviewer.as_tool(tool_name="technical_opinion",
            tool_description="Technical feasibility opinion. Pass the full text."),
    ],
    # The fake model calls the three tools in the same turn, then synthesises.
    model=FakeModel([
        [tool_call("legal_opinion", input=CLAUSE), tool_call("tax_opinion", input=CLAUSE),
         tool_call("technical_opinion", input=CLAUSE)],
        say("Verdicts: legal needs_changes, tax acceptable, technical unacceptable. "
            "Overall: unacceptable."),
    ], log),
)

async def main():
    result = await Runner.run(orchestrator, CLAUSE, max_turns=8)
    print(result.final_output)
    print("orchestrator tools:", log[0]["tools"], "| handoffs:", log[0]["handoffs"])
    print("last agent:", result.last_agent.name)
    print("the legal reviewer received:", seen["legal"][0]["input"])
    print("the orchestrator, on the second turn, read:",
          [m["output"][:60] for m in log[1]["input"] if m.get("type") == "function_call_output"])

asyncio.run(main())

# Output:
# Verdicts: legal needs_changes, tax acceptable, technical unacceptable. Overall: unacceptable.
# orchestrator tools: ['legal_opinion', 'tax_opinion', 'technical_opinion'] | handoffs: []
# last agent: Review coordinator
# the legal reviewer received: [{'content': 'The supplier guarantees 100% availability of the service and undertakes to refund the entire annual fee for any interruption longer than one minute.', 'role': 'user'}]
# the orchestrator, on the second turn, read: ["verdict='needs_changes' reason='A 100% guarantee is an oblig", "verdict='acceptable' reason='outside my scope' quote='The su", "verdict='unacceptable' reason='No system guarantees 100%.' q"]

The output contains three facts that matter. First: the orchestrator has three tools and no handoff, and the agent that closes the run is the orchestrator, not a sub-agent. Second: the legal reviewer received only the string passed in the tool's input argument, not the orchestrator's conversation; this is why the tool_description says "pass the full text". Third, and it is a detail you would not guess: the opinions come back to the orchestrator as the text of the Pydantic object (verdict='needs_changes' reason=...), not as JSON. It works for a model that reads them, but downstream parsing cannot count on it: if you need JSON, you produce it yourself with a custom output extractor (the custom_output_extractor of as_tool) or by orchestrating in code.

There are three design choices that make the pattern work.

  • "You must consult ALL THREE, always". Without this line the orchestrator may consult one or two, because it judges relevance by itself, and when it gets it wrong you do not notice because the result looks complete. If consulting all of them is a hard requirement, the correct form is not even a prompt: you call the three sub-agents yourself and pass the three opinions to a fourth agent that synthesises.
  • "The overall verdict is the harshest". The aggregation rule must be declared, otherwise the orchestrator makes an impressionistic average. If the rule is deterministic it is better to implement it in Python: max(verdicts, key=severity) never gets it wrong.
  • output_type on the sub-agents, not on the orchestrator. That way each opinion has a controlled shape, with the mandatory quote field that makes the answer verifiable, while the final synthesis stays text for the user.

The three choices have the same purpose: to move from the model to the code or to the schema everything that must be guaranteed. The orchestrator is left free only to synthesise, which is the only thing a model is needed for. The more you move towards the code, the less the quality of the result depends on a model that "remembers" to do the right thing.

Parallelism, which is the real gain

The model can ask for the three tools in a single turn, as in the script above; if the runner executes them together the total time is that of the slowest reviewer instead of the sum. If you want parallelism that is guaranteed, not hoped for, you write the orchestration yourself with asyncio, as in the article on asyncio. In the following block the fake models take one second to answer (SlowModel), to make the difference visible.

import asyncio
import json
import time
from typing import Literal
from pydantic import BaseModel
from agents import Agent, Runner
from fake import FakeModel, say

class Opinion(BaseModel):
    verdict: Literal["acceptable", "needs_changes", "unacceptable"]
    reason: str

class SlowModel(FakeModel):
    """Like the fake model, but it takes `seconds` to answer (like a network call)."""
    def __init__(self, script, seconds):
        super().__init__(script)
        self.seconds = seconds
    async def get_response(self, *args, **kwargs):
        await asyncio.sleep(self.seconds)
        return await super().get_response(*args, **kwargs)

def reviewer(name, verdict, seconds):
    reply = say(json.dumps({"verdict": verdict, "reason": "..."}))
    return Agent(name=name, instructions="Assess the clause.", output_type=Opinion,
                 model=SlowModel([reply], seconds))

async def parallel_review(reviewers: dict, clause: str, timeout_s: float) -> dict:
    """Three opinions in parallel, guaranteed. No decision left to the model."""
    async def one(name: str, agent):
        try:
            async with asyncio.timeout(timeout_s):
                result = await Runner.run(agent, clause, max_turns=4)
            return name, result.final_output
        except TimeoutError:
            return name, None

    pairs = await asyncio.gather(*(one(n, a) for n, a in reviewers.items()))
    opinions = dict(pairs)

    missing = [n for n, p in opinions.items() if p is None]
    if missing:
        return {"ok": False, "reviewers_missing": missing}

    severity = {"acceptable": 0, "needs_changes": 1, "unacceptable": 2}
    worst = max(opinions.values(), key=lambda p: severity[p.verdict])
    return {"ok": True,
            "verdict": worst.verdict,                 # deterministic aggregation
            "opinions": {n: p.model_dump() for n, p in opinions.items()}}

def team(technical_seconds: float) -> dict:
    return {"legal": reviewer("Legal", "needs_changes", 1.0),
            "tax": reviewer("Tax", "acceptable", 1.0),
            "technical": reviewer("Technical", "unacceptable", technical_seconds)}

async def main():
    clause = "100% availability guarantee."
    # Each reviewer takes 1 s: in parallel the total is about 1 s, in series it would be about 3 s.
    t0 = time.perf_counter()
    outcome = await parallel_review(team(1.0), clause, timeout_s=2)
    duration = time.perf_counter() - t0
    print(outcome["ok"], outcome["verdict"], "| less than the sum (3 s):", duration < 2.0)

    # A reviewer that does not answer in time: an explicit error, not an incomplete synthesis.
    print(await parallel_review(team(5), clause, timeout_s=1.5))

asyncio.run(main())

# Output:
# True unacceptable | less than the sum (3 s): True
# {'ok': False, 'reviewers_missing': ['technical']}

Three lines matter. asyncio.gather launches the three reviewers together, so with one second each the total is about one second and not three (the first line of the output confirms it with a wide threshold, because timings change from one machine to another and from one run to another). asyncio.timeout gives each reviewer a maximum time, and the except TimeoutError branch turns the delay into an explicit outcome: in the second run the technical reviewer takes five seconds, the timeout is 1.5 and the output says reviewers_missing: ['technical'] instead of a silently incomplete synthesis. Finally max(..., key=severity) is the deterministic aggregation.

This version is longer and better on three fronts: the parallelism is certain, the aggregation cannot be reinterpreted, and a missing reviewer is a visible error. The rule that follows: you leave orchestration to the model only when the plan is not known in advance. If you know that three reviewers must be called and that the verdict is the maximum, that is code.

The cost, measured

Here you see why the pattern must be dosed. I measured how many characters are sent to the models over the whole run, adding up orchestrator and sub-agents, for a clause of roughly a thousand characters, against a generalist agent that reads it once. They are characters, not tokens, and they count the JSON of the history with its escape sequences: the proportion is indicative, not a billing count.

import asyncio
from agents import Agent, Runner
from fake import FakeModel, tool_call, say

CLAUSE = ("The supplier guarantees 100% availability of the service "
          "and undertakes to refund the entire annual fee "
          "for any interruption longer than one minute. ") * 7      # a long clause
OPINION = "verdict='needs_changes' reason='" + "x" * 200 + "' quote='..."   # ~230 characters

def characters(log):
    return sum(v["chars"] for v in log)

async def with_sub_agents(k: int) -> int:
    log = []                       # a single log: orchestrator and sub-agents together
    reviewer_instructions = "You assess the clause from your profile and quote the text you rely on."
    reviewers = [Agent(name=f"Reviewer {i}", instructions=reviewer_instructions,
                       model=FakeModel([say(OPINION)], log)) for i in range(k)]
    tools = [r.as_tool(tool_name=f"opinion_{i}", tool_description="Opinion on a clause. Pass the full text.")
             for i, r in enumerate(reviewers)]
    orchestrator = Agent(name="Coordinator", instructions="Consult all the reviewers and synthesise.",
                         tools=tools, model=FakeModel([
                             [tool_call(f"opinion_{i}", input=CLAUSE) for i in range(k)],
                             say("Synthesis.")], log))
    await Runner.run(orchestrator, CLAUSE)
    return characters(log)

async def generalist() -> int:
    log = []
    agent = Agent(name="Generalist", instructions="You assess the clause from every profile.",
                  model=FakeModel([say("Synthesis.")], log))
    await Runner.run(agent, CLAUSE)
    return characters(log)

async def main():
    base = await generalist()
    print(f"generalist: {base} characters sent ({len(CLAUSE)} are the clause)")
    for k in (1, 2, 3, 5):
        n = await with_sub_agents(k)
        print(f"orchestrator + {k} sub-agents: {n:>6} characters sent, {n / base:.1f} times the generalist")

asyncio.run(main())

# Output:
# generalist: 1119 characters sent (1043 are the clause)
# orchestrator + 1 sub-agents:   5434 characters sent, 4.9 times the generalist
# orchestrator + 2 sub-agents:   8634 characters sent, 7.7 times the generalist
# orchestrator + 3 sub-agents:  11834 characters sent, 10.6 times the generalist
# orchestrator + 5 sub-agents:  18237 characters sent, 16.3 times the generalist

The cost grows linearly with the number of sub-agents: each additional sub-agent adds about 3,200 characters, that is more than three times the length of the clause. I did not break the figure down item by item, but the causes are evident in the flow: the text travels as the sub-agent's input, as the argument of the call that the orchestrator must write (and therefore generate), and again in the history that the orchestrator rereads on the second turn, together with the opinion. It is not a reason not to use it: it is a reason to know when the additional quality justifies a multiplier that, in this trial, goes from 5 times with one sub-agent to more than 16 with five. For the economic side, measuring and controlling cost per request, there is FinOps and cost optimisation for LLM agents.

Depth and recursion: the counter you really need

The max_turns cap protects one agent from a cycle within itself. It protects against nothing when the problem is nesting: an orchestrator with max_turns=8 that calls a sub-agent with max_turns=8, which in turn calls another, can produce many model calls while respecting each single cap. And if through carelessness agent A has agent B among its tools and B has A, the recursion is unbounded.

The defence is a depth counter that lives in the run context, that is in your own object that the SDK passes to the tools and that the model neither sees nor can modify. In the following block guarded_tool replaces as_tool: it builds a function tool that checks depth, path and budget before running the sub-agent, and writes one JSON log line per event (log_step), with the position in the tree always present. The trial has a circular dependency on purpose: the legal agent uses the tax agent and the tax agent uses the legal one. It is run with two depth limits to see the two checks.

import asyncio
import json
import time
from dataclasses import dataclass, field
from agents import Agent, Runner, function_tool, RunContextWrapper
from fake import FakeModel, tool_call, say

@dataclass
class ExecutionContext:
    run_id: str
    depth: int = 0
    max_depth: int = 2
    model_calls: int = 0
    max_model_calls: int = 25
    path: list[str] = field(default_factory=list)   # who called whom

class HierarchyLimit(RuntimeError):
    pass

def log_step(c: ExecutionContext, event: str, **extra) -> None:
    """One line per event, with the position in the tree always present."""
    line = {"run": c.run_id, "depth": c.depth,
            "path": "/".join(c.path) or "root",   # "legal/tax"
            "event": event, **extra}
    print(json.dumps(line, ensure_ascii=False))

def turn_count(result) -> int:
    return len(result.raw_responses)          # one model response per turn

def guarded_tool(agent: Agent, name: str, description: str):
    """Exposes an agent as a tool, with depth and budget checks."""

    @function_tool(name_override=name, description_override=description)
    async def invoke(context: RunContextWrapper[ExecutionContext], request: str) -> str:
        c = context.context

        if c.depth >= c.max_depth:
            return ("ERROR: maximum depth reached. "
                    "Answer with the data you already have and state what is missing.")
        if name in c.path:
            return (f"ERROR: {name} is already in the call path {c.path}. "
                    "Recursion not allowed: conclude with the available data.")
        if c.model_calls >= c.max_model_calls:
            raise HierarchyLimit(f"call budget exhausted on {c.path}")

        c.depth += 1
        c.path.append(name)
        log_step(c, "sub_agent_start", agent=name)
        try:
            result = await Runner.run(agent, request, context=c, max_turns=5)
            c.model_calls += turn_count(result)
            log_step(c, "sub_agent_end", agent=name, turns=turn_count(result))
            return str(result.final_output)
        finally:
            c.depth -= 1            # restore even on exception
            c.path.pop()

    return invoke

def build():
    """Three agents with a circular dependency: legal uses tax, tax uses legal."""
    seen = []
    legal = Agent(name="Legal", instructions="l", model=FakeModel([
        tool_call("tax", request="what about VAT?"), say("Complete legal opinion.")]))
    tax = Agent(name="Tax", instructions="f", model=FakeModel([
        tool_call("legal", request="what about the contract?"), say("Tax opinion with the data I have.")], seen))
    legal.tools = [guarded_tool(tax, "tax", "Tax opinion.")]
    tax.tools = [guarded_tool(legal, "legal", "Legal opinion.")]
    coordinator = Agent(name="Coordinator", instructions="c", model=FakeModel([
        tool_call("legal", request="assess the clause"), say("Synthesis.")]),
        tools=[guarded_tool(legal, "legal", "Legal opinion.")])
    return coordinator, seen

async def main():
    for max_depth in (2, 3):
        print(f"--- max_depth = {max_depth}")
        coordinator, seen = build()
        context = ExecutionContext("r-7", max_depth=max_depth)
        result = await Runner.run(coordinator, "clause", context=context)
        reply_to_tax = [m["output"] for m in seen[1]["input"] if m.get("type") == "function_call_output"]
        print("the tax agent read:", reply_to_tax)
        print("outcome:", result.final_output, "| turns counted:", context.model_calls, "| final depth:", context.depth)

asyncio.run(main())

# Output:
# --- max_depth = 2
# {"run": "r-7", "depth": 1, "path": "legal", "event": "sub_agent_start", "agent": "legal"}
# {"run": "r-7", "depth": 2, "path": "legal/tax", "event": "sub_agent_start", "agent": "tax"}
# {"run": "r-7", "depth": 2, "path": "legal/tax", "event": "sub_agent_end", "agent": "tax", "turns": 2}
# {"run": "r-7", "depth": 1, "path": "legal", "event": "sub_agent_end", "agent": "legal", "turns": 2}
# the tax agent read: ['ERROR: maximum depth reached. Answer with the data you already have and state what is missing.']
# outcome: Synthesis. | turns counted: 4 | final depth: 0
# --- max_depth = 3
# {"run": "r-7", "depth": 1, "path": "legal", "event": "sub_agent_start", "agent": "legal"}
# {"run": "r-7", "depth": 2, "path": "legal/tax", "event": "sub_agent_start", "agent": "tax"}
# {"run": "r-7", "depth": 2, "path": "legal/tax", "event": "sub_agent_end", "agent": "tax", "turns": 2}
# {"run": "r-7", "depth": 1, "path": "legal", "event": "sub_agent_end", "agent": "legal", "turns": 2}
# the tax agent read: ["ERROR: legal is already in the call path ['legal', 'tax']. Recursion not allowed: conclude with the available data."]
# outcome: Synthesis. | turns counted: 4 | final depth: 0

With max_depth = 2 the legal → tax chain reaches the limit and the tax agent receives the depth error; with 3 it passes the first check and the path check intervenes, which detects that legal is already in the chain. In both cases the run closes with the synthesis, the counter returns to zero (finally) and no exception rises. Four details make this wrapper different from a plain as_tool().

The first two checks return an error as text instead of raising. It is the same choice as in the article on tools: "I have reached the depth limit" is a situation the orchestrator can recover from, by concluding with the data it has. An exception would kill the whole run for one missing sub-agent, losing the opinions already collected. The third check, instead, raises: the overall call budget is not recoverable, and continuing only means spending. In the code the counter adds up the turns of the sub-agents (turn_count reads raw_responses) and does not count the coordinator's turns.

The path check closes the recursion. name in c.path detects not only A→A but also A→B→A, which is the form nobody anticipates and that occurs when two tools were registered at different moments of development. The path is also the information to have in the logs: "legal/tax" explains an anomalous bill at a glance. The finally restores the depth: without it a sub-agent that raises leaves the counter incremented, and after a few exceptions the system believes it is deep while being at the root, refusing legitimate calls. It is a failure that shows up only under error, therefore only in production.

On the diagnosis front, the trace of a run with sub-agents is a tree, but tools often show it as a flat list. With the path field on every line three questions become text searches: which sub-agents were called in this run (filter by run), which branch cost the most turns (group by path), why the synthesis does not cite the tax opinion (search for tax: if it does not appear, the sub-agent was not called, and the problem is one of orchestration, not of synthesis). The run field must be the same for the whole hierarchy, and that is why the context is passed to the sub-agents with context=c instead of creating a new one.

One last point concerns design more than code: two levels are usually enough. An orchestrator and its specialists are understandable, traceable and discussable. At the third level the cost multiplies again and the trace becomes a tree nobody reads. The default max_depth = 2 is a choice of prudence, not an SDK limit: change it only for a measured reason.

A special sub-agent: the verifier

There is a use of the pattern that is not "ask a specialist" but "have your own work checked". The writer produces a draft, passes it to a verifier as a tool, and if a rejection comes back it rewrites. It is the produce-verify-correct cycle, which Anthropic calls evaluator-optimizer and recommends when clear evaluation criteria exist. The verifier in this trial is a deterministic check on the word count, because the specification is objective (at most twelve words) and the writer's script sends two drafts, one too long and one correct.

import asyncio
import json
from typing import Literal
from pydantic import BaseModel, Field
from agents import Agent, Runner
from fake import FakeModel, tool_call, say

class Verification(BaseModel):
    outcome: Literal["approved", "redo"]
    problems: list[str] = Field(description="One per line, concrete and actionable")

SPECIFICATION = "At most 12 words."

def objective_check(user_input):
    """The 'verifier' of this trial: a deterministic check against the specification."""
    message = user_input if isinstance(user_input, str) else user_input[-1]["content"]
    words = len(message.split("\nSPECIFICATION:")[0].replace("TEXT:", "").split())
    if words > 12:
        return say(json.dumps({"outcome": "redo",
                               "problems": [f"{words} words, the limit is 12"]}))
    return say(json.dumps({"outcome": "approved", "problems": []}))

verifier = Agent(
    name="Verifier",
    instructions=(
        "You receive a text and the specification it had to meet. "
        "Check ONLY compliance with the specification, point by point. "
        "Do not rewrite the text, do not give style advice. "
        "If a requirement is not met, outcome='redo' and say so, citing the point."
    ),
    output_type=Verification,
    model=FakeModel(objective_check),
)

DRAFT_1 = "The service stays up almost always but in rare cases may stop for scheduled maintenance"
DRAFT_2 = "The service may stop only for scheduled maintenance."

log = []
writer = Agent(
    name="Writer",
    instructions=(
        "Write the requested text. Then ALWAYS call verify_text passing "
        "your text AND the specification. If 'redo' comes back, fix the problems "
        "listed and verify again. At most two corrections, then deliver, "
        "stating what remains non-compliant."
    ),
    tools=[verifier.as_tool(
        tool_name="verify_text",
        tool_description=("Checks a text against a specification. "
                          "Pass both the text and the full specification."))],
    model=FakeModel([
        tool_call("verify_text", input=f"TEXT: {DRAFT_1}\nSPECIFICATION: {SPECIFICATION}"),
        tool_call("verify_text", input=f"TEXT: {DRAFT_2}\nSPECIFICATION: {SPECIFICATION}"),
        say(DRAFT_2),
    ], log),
)

async def main():
    result = await Runner.run(writer, "Explain the availability of the service.")
    print("delivered text:", result.final_output)
    for m in log[-1]["input"]:
        if m.get("type") == "function_call_output":
            print("the verifier answered:", m["output"])

asyncio.run(main())

# Output:
# delivered text: The service may stop only for scheduled maintenance.
# the verifier answered: outcome='redo' problems=['15 words, the limit is 12']
# the verifier answered: outcome='approved' problems=[]

The delivered text is the second draft, and the last two lines of the output show the path: the first is rejected with a concrete problem ("15 words, the limit is 12"), the second approved. Two details distinguish a useful cycle from an infinite one. "At most two corrections, then deliver, stating what remains non-compliant" prevents the bounce between writer and verifier, which would otherwise continue up to max_turns with the user waiting. And "do not rewrite the text" on the verifier stops it from becoming a second writer, whose proposal the writer adopts without assessing it.

The limit to know: the scheme works when verifying is easier than producing and relies on an external criterion (a written specification, a schema, a test that runs). It does not work as "think harder". Huang et al. (Large Language Models Cannot Self-Correct Reasoning Yet) report that, without an external signal of correctness, a model's self-correction of its own reasoning does not improve and can worsen the answers. The practical consequence is that the verifier must have something objective to check, otherwise you pay two calls to add noise. Where no objective check exists, a deterministic guardrail or a human review is better, and they are the topic of the next two articles: guardrails and the real traps.

Choosing between the two forms

  • Do you need the answer to process it? Agent-as-tool. Do you need it to carry on? Handoff.
  • Do you need to compare several opinions? Agent-as-tool (the handoff goes to one place only).
  • Must the sub-agent talk to the user? Handoff (with agent-as-tool the user never sees it).
  • Does input cost matter? Handoff (one agent at a time, no duplication of the input).
  • Does it matter to have a single point of responsibility for the final answer? Agent-as-tool: the orchestrator is the only one that speaks, hence the only place where you can impose tone, format and output guardrails.

The five questions revolve around who must have the last word. If the last word must belong to the specialist, handoff; if it must stay with a single point that controls the shape of the answer, agent-as-tool. When the answers diverge (you want the specialist's voice but also an output check) the compromise is often a handoff with a guardrail on the specialist's output, which is the topic of the article on guardrails.

Trade-offs

  • Quality versus cost. Specialists with targeted instructions may get things wrong less than a generalist, and you pay for it in multiplied input (measured above) and in latency, if you do not parallelise.
  • Control versus autonomy. Orchestrating in Python gives you certain parallelism and deterministic aggregation; orchestrating with the model handles the cases you had not foreseen. The practical middle way: fixed plan in code, and an "other" branch entrusted to the model.
  • Duplicated context. Each sub-agent receives its own input: it does not see the orchestrator's conversation and does not inherit its confusion, but you have to pass it explicitly everything it needs. Forgetting a piece is the typical mistake.
  • Depth of the hierarchy. Two levels are manageable. Three levels multiply cost and latency and make traces hard to read.

The four items hold together a single criterion: every time you add a level of autonomy you add a cost of control. For a system that must answer repeatably, start from the code side and give the model only the synthesis; shift responsibility to the model when a measurement (not an impression) shows that the fixed plan does not cover the real cases.

The traps you really fall into

  1. Unintended recursion. If A has B as a tool and B has A as a tool, you have a recursion that shows up as a cost explosion. max_turns protects the single agent, not the depth of nesting: you need a counter in the run context.
  2. Not passing the context to the sub-agent. The orchestrator calls legal_opinion("assess the clause") without attaching the text. The sub-agent does not have it, and invents. The tool_description must say so ("pass the full text").
  3. Leaving the aggregation to the model when the rule is known. "The harshest of the three" is max: it costs one line and does not get it wrong.
  4. A sub-agent that returns a wall of text. Its answer becomes the orchestrator's context and you pay for it on every following turn. Constrain the sub-agents to short, typed outputs.
  5. No timeout on the sub-agents. A sub-agent that spins idle blocks the whole tree. A timeout for each one, and an explicit outcome for the "missing" case.
  6. Unreadable traces. Without a shared run identifier and a path, the trace of a hierarchical run is a flat list in which you cannot tell who called whom.

The six traps share a cause: in the hierarchy errors are no longer local. A wrong sub-agent does not make the run fail, it poisons its synthesis; a cycle does not explode at once, it accumulates in invoices. The defences seen in this article (explicit context, aggregation in code, timeouts, depth counter, tree logs) all serve to make the error visible at the point where it arises.

What you can do now

You can use as_tool to expose an agent as a tool and you know that the sub-agent receives only the string you pass it, and that its results come back as text. You can orchestrate in Python with asyncio.gather, a timeout per reviewer and deterministic aggregation, and you can measure the input multiplier of the hierarchy on your own case. You can protect a hierarchy from recursion with a depth counter in the context, and build a writer-verifier cycle with an external criterion. In the next article we move on to safety: guardrails, that is the checks that run alongside the agent and can stop it.

Sources

All the listings were run twice with Python 3.14.4 and openai-agents 0.24.0, in a temporary virtual environment, without calling any model, with the SDK's tracing disabled: the outputs are identical (the parallelism block compares the time against a wide threshold). The opinions, the orchestrator's decisions and the text of the drafts come from the scripts. The character counts depend on the invented text of the clause. The scenarios are invented for the example.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects