Chapter 27 — Agent-as-tool
Why this chapter
Every multi-agent pattern so far in this series either crosses a process boundary (this repo’s default tool orchestration mode, where the orchestrator’s LLM calls call_specialist_agent, which is an A2A HTTP call dressed up as a tool call) or hands control away entirely (Chapter 14’s HandoffBuilder mesh, where the receiving agent takes the floor and decides for itself when to hand back). Neither is the right shape for the common case of “this agent needs another agent’s capability as a single well-defined step, in the same process, and then wants to keep going.”
MAF v1 has a third option for exactly that case: Agent.as_tool(...). It wraps any Agent object as an ordinary FunctionTool — the same shape as any @tool-decorated function — that another agent can add to its own tools=[...]. No network hop, no mesh topology, no handoff bookkeeping. Control returns to the caller automatically the instant the wrapped agent finishes, exactly like any other tool call. This chapter builds a small “coordinator” agent that calls a small “product-lookup” agent this way, and makes the automatic-return-of-control visible: the coordinator gets the looked-up product back, then keeps going and calls a second, ordinary tool to compute a discount — something a sub-agent that had “taken the floor” via handoff could not be made to do for it.
The concept
Three patterns in this codebase all look like “one agent uses another,” and it’s easy to conflate them once you’ve read docs/concepts/06-orchestration-patterns.md, which documents two of the three but doesn’t name the third. Laid out side by side:
- A2A-as-tool (this repo’s real
toolorchestration mode) — cross-process. The orchestrator’s LLM callscall_specialist_agent, which does an HTTP POST to a specialist’s/message:sendor/message:streamendpoint (agents/python/orchestrator/agent.py:40). The callee is a separate running service with its own port, its own process, its own failure modes (timeouts, connection refused). It only looks like a tool call from the LLM’s point of view; underneath it’s a network request. Agent.as_tool()(this chapter) — in-process.agent.as_tool(...)wraps an already-builtAgentobject into aFunctionTool; no socket is opened, no serialization crosses a process boundary. The wrapped agent runs in the same Python process, in the sameawait, as the caller. Control returns to the caller automatically once the wrapped agent produces its final response — same as any tool call returning a value.- Handoff (
HandoffBuilder, Chapter 14) — control transfers. The target agent doesn’t just answer and hand a return value back; it takes over the conversation and can itself decide to hand back, hand off again, or just keep talking. The caller doesn’t automatically get control back the way it does with a tool call.
The problem Agent.as_tool() solves is composition without the overhead of the other two: you get a small, well-scoped agent (its own instructions, its own tools, its own reasoning) usable as a single callable capability inside a bigger agent’s toolset — no HTTP client, no mesh of add_handoff(...) edges to maintain, no risk of an unbounded back-and-forth. Reach for it when the composition is in-process, same deployment, and the relationship between the two agents is “call it, get an answer, move on” — a single well-defined step, not a multi-turn conversation between them. Reach for A2A instead when the callee is a genuinely separate service (different deployment, different scaling, different team). Reach for handoff instead when the interaction needs more than one round trip between the two agents, or when the receiving agent should be free to keep driving the conversation rather than just answering and yielding back.
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af',
'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981',
'background': 'transparent'}}}%%
flowchart LR
accTitle: The concept
classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef external fill:#f59e0b,stroke:#b45309,color:#000000
classDef success fill:#10b981,stroke:#047857,color:#ffffff
user([User question])
coord[Coordinator agent]
llm[(LLM)]
lookupTool[[product_lookup FunctionTool]]
lookupAgent[Product-lookup agent]
catalogTool[[search_catalog tool]]
discountTool[[calculate_discount tool]]
answer([Final answer])
user --> coord
coord -- "prompt + tool schemas" --> llm
llm -- "calls product_lookup" --> coord
coord -- "invokes FunctionTool" --> lookupTool
lookupTool -- "runs in-process" --> lookupAgent
lookupAgent -- "calls search_catalog" --> catalogTool
catalogTool -- "result" --> lookupAgent
lookupAgent -- "final response" --> lookupTool
lookupTool -- "return value" --> coord
coord -- "control returns automatically" --> llm
llm -- "calls calculate_discount" --> coord
coord -- "invokes function" --> discountTool
discountTool -- "result" --> coord
coord -- "combined result in context" --> llm
llm -- "final text" --> coord
coord --> answer
class coord core
class lookupAgent core
class llm external
class lookupTool core
class catalogTool core
class discountTool core
class answer success
The wrapped agent never “keeps” the conversation — once product_lookup returns its string, the coordinator is back in the driver’s seat and free to call a second, unrelated tool before answering.
Python
Run from the repo root using the shared tutorials/ uv project (one uv sync covers every chapter):
uv sync --project tutorials
uv run --project tutorials python tutorials/27-agent-as-tool/python/main.py
Source: python/main.py. The product-lookup agent is an ordinary Agent with one ordinary tool:
@tool(name="search_catalog", description="Look up a product in the catalog by name.")
def search_catalog(
name: Annotated[str, Field(description="The product name to look up, e.g. 'Wireless Headphones'.")],
) -> str:
item = CATALOG.get(name.lower().strip())
if item is None:
return f"No catalog entry for '{name}'."
return (
f"{name.title()}: ${item['price']:.2f}, category {item['category']}, "
f"{item['stock']} in stock."
)
def build_product_lookup_agent(client: object | None = None) -> Agent:
return Agent(
client or _default_client(),
instructions=PRODUCT_LOOKUP_INSTRUCTIONS,
name="product-lookup-agent",
description="Looks up product price, category, and stock in the catalog.",
tools=[search_catalog],
)
build_agent() is the entire point of this chapter — wrap that agent with .as_tool() and hand the resulting FunctionTool to the coordinator’s own tools=[...], next to an ordinary local tool:
def build_agent(client: object | None = None) -> Agent:
resolved_client = client or _default_client()
product_lookup_agent = build_product_lookup_agent(resolved_client)
product_lookup_tool = product_lookup_agent.as_tool(
name="product_lookup",
description="Delegate a product question to the product-lookup specialist agent.",
arg_name="task",
)
return Agent(
resolved_client,
instructions=COORDINATOR_INSTRUCTIONS,
name="coordinator-agent",
tools=[product_lookup_tool, calculate_discount],
)
Run it with the default question — "Look up the Wireless Headphones, then tell me the price after a 20% discount." — and the coordinator calls product_lookup, gets back "Wireless Headphones: $149.99, category Electronics, 42 in stock.", and then — still in control, on its own next turn — calls calculate_discount(149.99, 20) and folds both results into one answer: "The Wireless Headphones are priced at $149.99. After applying a 20% discount, the price comes to $119.99." Nothing about the coordinator’s follow-up decision required the product-lookup agent to “hand back” anything — it never had control to begin with.
Gotchas
.as_tool()returns aFunctionTool, not a live handle to the agent. Calling it doesn’t start a conversation you can keep talking to — every invocation runs the wrapped agent fresh (or, withpropagate_session=True, forwards the caller’s session) and returns a single string. If you need the two agents to go back and forth more than once per turn, this is the wrong tool — reach forHandoffBuilder(Chapter 14) instead.- Zero usages in this repo’s production app today.
agents/python/orchestrator/agent.pyand every specialist agent were grepped foras_tool(/AsAIFunctionand neither appears anywhere underagents/,orchestrator/,web/, ordocs/outside this chapter. The capstone’stoolorchestration mode composes agents over A2A HTTP instead (see “How this shows up in the capstone” below) —Agent.as_tool()is a real, documented MAF capability this codebase simply hasn’t reached for yet, not a pattern retrofitted from existing code. - The wrapped agent’s
arg_namedefault is"task", not"input"or"query". The LLM calling the wrapper sees a single string argument named whateverarg_namesays ("task"by default) with an auto-generated description (f"Task for {tool_name}") unless you overridearg_description— a vague default description makes the calling LLM more likely to pass a malformed or underspecified task string. propagate_session=Falseby default. The wrapped agent gets an independent session per call unless you explicitly opt into sharing the parent’s session — usually what you want for a narrow, single-purpose lookup likeproduct_lookuphere, since it shouldn’t accumulate unrelated conversation history across calls.- Descriptions on both the wrapped agent and the tool matter. The LLM deciding whether to call
product_lookuponly seesname+description+ thetaskargument’s schema — it never sees the wrapped agent’s owninstructions. If the tool-leveldescriptionis vague, the coordinator may under- or over-use it exactly like any other under-described tool (see Chapter 02’s Gotchas).
Tests
uv run --project tutorials pytest tutorials/27-agent-as-tool/python/tests -v
tutorials/27-agent-as-tool/python/tests/test_agent_as_tool.py covers, structurally:
- Unit tests against the tool functions directly —
search_catalogandcalculate_discount, no LLM involved. - Agent-wiring tests — the coordinator’s registered tools include both
product_lookup(the wrapped agent) andcalculate_discount; the product-lookup agent’s registered tools includesearch_catalog; and.as_tool()genuinely returns aFunctionTool, not the raw agent. - A replay test (
test_replay_coordinator_combines_lookup_and_discount) that plays back committed fixtures intests/fixtures/replay/— no network or credentials required, safe for CI. - Real-LLM integration tests, skipped unless usable credentials are present — one asserts the coordinator keeps control after the wrapped agent answers and goes on to call
calculate_discountitself; the other asserts a lookup-only question still resolves correctly through the wrapped agent alone.
How this shows up in the capstone
Honestly: it doesn’t, yet. Agent.as_tool() has zero call sites in agents/, orchestrator/, or web/ as of this chapter — the capstone’s default tool orchestration mode solves the “one agent uses another” problem with A2A over HTTP instead, since its specialists are genuinely separate deployments, not in-process objects. agents/python/orchestrator/agent.py:40 is call_specialist_agent, the real contrast case:
async def call_specialist_agent(
agent_name: Annotated[str, Field(description="Name of the specialist agent to call")],
message: Annotated[str, Field(description="The message/request to send to the specialist agent")],
) -> str:
"""Call a specialist agent and return its response."""
url = AGENT_REGISTRY.get(agent_name)
It has the same shape from the LLM’s point of view as this chapter’s product_lookup tool — a single string argument, a single string result — but everything past url = AGENT_REGISTRY.get(...) is an httpx call to another process’s /message:send or /message:stream endpoint (agents/python/orchestrator/agent.py:66-90), not an in-process agent.run(). That’s the whole distinction this chapter teaches, visible in one real file: same tool-shaped interface, completely different implementation underneath, because the capstone’s specialists are separate services and this chapter’s product-lookup agent isn’t.
What’s next
- Related: Chapter 14 — Handoff Orchestration for when control should actually transfer instead of returning automatically.
- Concepts: Orchestration patterns
- Full source:
python/ - Shared: Mermaid style guide · Jargon glossary
Source: tutorials/27-agent-as-tool/README.md — this page is generated from the repository.