Agentic AI Engineering with LangChain / Lab 10
Module 10 · Multi-agent systems

Lab 10 · The support org chart

Build one support organisation twice, as supervisor-plus-subagents and as agent handoffs, and count the model calls each pattern costs.

60–75 minAdvancedAzure OpenAIsubagentshandoffsCommand.PARENTToolRuntimecallbacks

What you will build

A Northwind support team of three specialists (billing, technical, knowledge) plus a human escalation queue. One factory, build_org(pattern=...), builds it with either of the two main multi-agent patterns:

  • Subagents: a supervisor calls specialists as tools. Control always returns to the supervisor.
  • Handoffs: the supervisor transfers the conversation. The specialist keeps it until someone transfers it away.

You run the same two-turn conversation through both and compare how many model calls each one needs.

What you will learn

  • How to wrap an agent as a tool, so it works with an isolated context
  • How a handoff tool returns Command(goto=..., graph=Command.PARENT) to move control
  • Why a handoff must forward the AIMessage that asked for it
  • How an entry node lets the current owner keep the thread on the next turn
  • How to count model calls with a callback handler, subagents included
1

Check your prerequisites

You need three things on your machine and three values from your instructor.

  • Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
  • A code editor. VS Code with the Python extension works well.
  • A terminal. PowerShell on Windows, Terminal on macOS or Linux.
PowerShell
python --version
i

From your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.

2

Create the project folder and virtual environment

Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.

PowerShell
mkdir C:\labs\lab-10-support-org
cd C:\labs\lab-10-support-org
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.

When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.

!

PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.

This is what your folder will look like at the end of the lab:

lab-10-support-org/ ├── .env ├── model_setup.py ├── requirements.txt └── support_org.py
3

Install the libraries

Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.

requirements.txt
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11

Then install everything into your virtual environment. It takes a minute or two.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Add your Azure OpenAI settings

Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.

.env
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>

# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview
  • AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in /openai/v1, keep it that way. If it is just https://<resource>.openai.azure.com/, paste that; the helper in the next step handles both.
  • AZURE_OPENAI_API_KEY is your secret key.
  • AZURE_OPENAI_DEPLOYMENT is the deployment name, for example gpt-5-mini. On Azure you call a deployment, not a model name.
!

Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.

5

Create the Azure OpenAI helper

Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.

Block 1Imports, .env loading and a console fix

load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.

The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.

model_setup.py (add to the end of the file)
"""Azure OpenAI model setup shared by every lab.

Import it in lab code as:  from model_setup import get_model, get_embeddings
"""

import asyncio
import os
import sys

from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel

# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()

# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

Block 2Read settings safely

_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.

_uses_v1_api() checks the shape of your endpoint. Azure offers two:

  • https://<resource>.openai.azure.com/openai/v1 is the newer OpenAI-compatible API.
  • https://<resource>.openai.azure.com/ is the classic API that needs an api_version.
model_setup.py (add to the end of the file)
def _setting(name: str) -> str:
    """Return a required setting from .env, or stop with a readable message."""
    value = os.getenv(name, "").strip()
    if not value:
        sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
    return value


def _uses_v1_api(endpoint: str) -> bool:
    """True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
    return endpoint.rstrip("/").endswith("/openai/v1")

Block 3Build the chat model

get_model() returns a LangChain chat model connected to your deployment.

  • With a /openai/v1 endpoint it uses ChatOpenAI with base_url. On Azure the deployment name goes in model.
  • With a classic endpoint it uses AzureChatOpenAI with azure_deployment and api_version.

Either way the lab code just calls get_model() and never cares which one it got.

model_setup.py (add to the end of the file)
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
    """Return a chat model for your Azure OpenAI deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)

    from langchain_openai import AzureChatOpenAI

    return AzureChatOpenAI(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
        **kwargs,
    )

Block 4Build the embeddings model

get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.

model_setup.py (add to the end of the file)
def get_embeddings(deployment: str | None = None) -> Embeddings:
    """Return an embeddings model for your Azure OpenAI embeddings deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import OpenAIEmbeddings

        return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)

    from langchain_openai import AzureOpenAIEmbeddings

    return AzureOpenAIEmbeddings(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
    )

Block 5A Windows fix for async code

use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.

model_setup.py (add to the end of the file)
def use_stable_event_loop() -> None:
    """On Windows, use the Selector event loop so async MCP code exits cleanly."""
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

Check that the helper can reach your deployment with a one-line test:

PowerShell
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"

You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.

6

Write the support org

A multi-agent system is several agents, each with a narrow job and a short prompt, plus a rule for who talks next. The two patterns in this lab differ in that rule:

  • Subagents: supervisor → ask_billing(brief) → answer → supervisor. The specialist sees only the brief, never the chat history.
  • Handoffs: entry → supervisor → transfer_to_billing → billing. The billing agent now owns the thread and answers the customer directly.

Neither is always better. Subagents keep context small and control central; handoffs skip the middle-man on follow-up questions.

Create an empty file named support_org.py in your lab folder. Then copy each block below into it, in order, one after another.

Block 1Imports

  • ToolRuntime lets a tool read the agent's state and its own tool call id.
  • Command moves control between graph nodes.
  • BaseCallbackHandler is the hook you use to count model calls.
  • InMemorySaver keeps each conversation between turns.
support_org.py (add to the end of the file)
"""Lab 10: the Northwind support org chart, built twice.

`build_org(model, pattern=...)` returns the same organisation as subagents or as handoffs, so
the two patterns can be swapped, and costed, with one argument.

Run:  python support_org.py
"""

from typing import Any, Literal, NotRequired

from langchain.agents import AgentState, create_agent
from langchain.messages import AIMessage, HumanMessage, ToolMessage
from langchain.tools import BaseTool, ToolRuntime, tool
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.language_models import BaseChatModel
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command

from model_setup import get_model

Block 2The back office and four tools

Fake data and four tools, one per job. Each specialist will get only the tools it needs. HUMAN_QUEUE stands in for a ticketing system.

support_org.py (add to the end of the file)
ORDERS = {"A-100": "shipped, total 249.00 USD", "A-101": "processing, total 1299.50 USD"}
HUMAN_QUEUE: list[str] = []


@tool
def lookup_order(order_id: str) -> str:
    """Look up a Northwind order/invoice by id, such as A-100."""
    return f"Order {order_id.upper()}: {ORDERS.get(order_id.upper(), 'not found')}."


@tool
def search_docs(query: str) -> str:
    """Search the Northwind product documentation (SSO, webhooks, the API)."""
    return "SSO: re-upload the IdP metadata in Admin -> Security -> SAML after a cert roll."


@tool
def search_faq(query: str) -> str:
    """Search the Northwind policy FAQ (refunds, trials, data residency)."""
    return "Refunds run for 30 days from the invoice date, pro-rated."


@tool
def escalate_to_human(summary: str) -> str:
    """Hand the request to a human support engineer."""
    HUMAN_QUEUE.append(summary)
    return f"Escalated as ESC-{len(HUMAN_QUEUE):03d}. A human will reply within a business day."

Block 3State, roster and supervisor prompt

SupportState extends the standard agent state with active_agent: the name of the agent that owns the conversation (used by handoffs).

SPECIALISTS maps each name to its scope and tools. The supervisor prompt is built from that table, so adding a specialist updates the prompt too. The prompt says the specialist cannot see this conversation, which pushes the supervisor to write a self-contained brief.

support_org.py (add to the end of the file)
class SupportState(AgentState):
    """Agent state plus the name of the agent that currently owns the conversation."""

    active_agent: NotRequired[str]


SPECIALISTS = {
    "billing": ("invoices, refunds, order status and anything about money", [lookup_order]),
    "technical": ("product errors, API questions, SSO and webhooks", [search_docs]),
    "knowledge": ("policy: refund windows, trials, data residency", [search_faq]),
}

SUPERVISOR_PROMPT = (
    "You are the Northwind support supervisor. Never answer from your own knowledge: delegate "
    "one topic to one specialist with a short self-contained brief (the specialist cannot see "
    "this conversation). Escalate to a human when the customer asks for one.\n"
    + "\n".join(f"- {name}: {scope}" for name, (scope, _) in SPECIALISTS.items())
)

Block 4Build one specialist

Each specialist is a normal create_agent with its own tools and a two-sentence rule. extra_tools is used by the handoff pattern to add transfer tools.

name=f"{name}-specialist" is the label you will see in traces, so give every agent a clear name.

support_org.py (add to the end of the file)
def build_specialist(name: str, model: BaseChatModel, extra_tools: tuple[BaseTool, ...] = ()):
    """Compile one specialist. `name=` is the label you read in a LangSmith trace."""
    scope, tools = SPECIALISTS[name]
    return create_agent(
        model=model,
        tools=[*tools, *extra_tools],
        system_prompt=f"You are the Northwind {name} specialist. You handle {scope}. Answer "
        "from your tools only, in two sentences.",
        state_schema=SupportState,
        name=f"{name}-specialist",
    )

Block 5Pattern 1: a specialist as a tool

specialist_as_tool wraps a specialist in a @tool named ask_billing (and so on). The tool starts a fresh run with only the brief and returns only the final text.

That isolation is the point: the specialist's own tool calls and messages never enter the supervisor's history, so the supervisor's context stays small.

support_org.py (add to the end of the file)
def specialist_as_tool(name: str, model: BaseChatModel) -> BaseTool:
    """Expose a specialist as a tool: a task brief in, its final text out, nothing else."""
    agent = build_specialist(name, model)

    @tool(f"ask_{name}", description=f"Ask the {name} specialist about {SPECIALISTS[name][0]}.")
    def ask(task: str) -> str:
        """Run the specialist on the brief alone and return its final answer."""
        return agent.invoke({"messages": [HumanMessage(task)]})["messages"][-1].text

    return ask

Block 6Pattern 2: a handoff tool

handoff_tool(target) builds transfer_to_<target>. Instead of a string it returns a Command:

  • goto=target names the next node.
  • graph=Command.PARENT says that node is in the parent graph, not inside this agent.
  • update sets active_agent and forwards two messages: the AIMessage that asked for the transfer and a ToolMessage acknowledgement. Forward both: a tool result without its tool call is an invalid history and the next model call fails.
support_org.py (add to the end of the file)
def handoff_tool(target: str) -> BaseTool:
    """Build a tool that transfers control to `target` in the PARENT graph."""

    @tool(f"transfer_to_{target}", description=f"Transfer the conversation to {target}.")
    def transfer(runtime: ToolRuntime) -> Command:
        """Hand the conversation, and ownership of the thread, to the target."""
        requesting_ai = runtime.state["messages"][-1]  # the AIMessage holding this tool call
        ack = ToolMessage(f"Transferred to {target}.", name=f"transfer_to_{target}",
                          tool_call_id=runtime.tool_call_id)
        # Forward both: a tool result whose tool call was left behind is a broken history.
        return Command(
            goto=target,
            update={"active_agent": target, "messages": [requesting_ai, ack]},
            graph=Command.PARENT,
        )

    return transfer

Block 7The entry node and the human queue

entry runs first on every turn and jumps to whoever owns the thread, defaulting to the supervisor. On the second turn the billing agent answers directly, which is what makes handoffs cheaper on follow-ups.

human_queue is deterministic: no model call, so escalation can never loop.

support_org.py (add to the end of the file)
_Targets = Literal["supervisor", "billing", "technical", "knowledge"]


def entry(state: SupportState) -> Command[_Targets]:
    """Whoever owns the thread keeps it: this is what makes turn 2 cheaper than turn 1."""
    return Command(goto=state.get("active_agent", "supervisor"))


def human_queue(state: SupportState) -> dict[str, Any]:
    """Escalation is a deterministic node: no model call, so this path cannot loop."""
    HUMAN_QUEUE.append(state["messages"][0].text)
    ticket = f"ESC-{len(HUMAN_QUEUE):03d}"
    return {"messages": [AIMessage(f"I have escalated this to a human engineer ({ticket}).")]}

Block 8One factory, two patterns

  • Subagents: one create_agent whose tools are the three ask_* wrappers plus escalate_to_human.
  • Handoffs: a StateGraph with entry, supervisor, human_queue and one node per specialist. Each specialist gets transfer tools to the other specialists and the human queue, never to itself.

There are no edges out of supervisor: its transfer tools route with Command.

support_org.py (add to the end of the file)
def build_org(model: BaseChatModel | None = None,
              pattern: Literal["subagents", "handoffs"] = "subagents",
              checkpointer: BaseCheckpointSaver | None = None):
    """Build the Northwind support org with one of the two multi-agent patterns."""
    model = model or get_model()
    if pattern == "subagents":
        return create_agent(
            model=model,
            tools=[*(specialist_as_tool(name, model) for name in SPECIALISTS), escalate_to_human],
            system_prompt=SUPERVISOR_PROMPT,
            state_schema=SupportState,
            checkpointer=checkpointer,
            name="support-supervisor",
        )

    supervisor = create_agent(
        model=model,
        tools=[*(handoff_tool(name) for name in SPECIALISTS), handoff_tool("human_queue")],
        system_prompt=SUPERVISOR_PROMPT + "\nTransfer control; never answer yourself.",
        state_schema=SupportState,
        name="support-supervisor",
    )
    builder = StateGraph(SupportState)
    builder.add_node("entry", entry)
    builder.add_node("supervisor", supervisor)
    builder.add_node("human_queue", human_queue)
    for name in SPECIALISTS:
        peers = [handoff_tool(other) for other in SPECIALISTS if other != name]  # never itself
        extra = (*peers, handoff_tool("human_queue"))
        builder.add_node(name, build_specialist(name, model, extra))
        builder.add_edge(name, END)
    builder.add_edge(START, "entry")
    builder.add_edge("human_queue", END)
    return builder.compile(checkpointer=checkpointer)

Block 9Count model calls

A callback handler receives events from every LangChain component in the run. on_chat_model_start fires once per model call, including calls made inside a subagent, because callbacks in the config are passed down.

support_org.py (add to the end of the file)
class CallCounter(BaseCallbackHandler):
    """Counts every model call in a run, the ones inside subagents included."""

    def __init__(self) -> None:
        """Start at zero."""
        self.model_calls = 0

    def on_chat_model_start(self, serialized: Any, messages: Any, **kwargs: Any) -> None:
        """Called by LangChain each time any chat model starts."""
        self.model_calls += 1

Block 10Run the same conversation through both patterns

For each pattern the demo builds the org with an InMemorySaver, so both turns share one thread, and asks two billing questions. Per turn it prints:

  • tools: every tool call added to the main history in that turn,
  • owner: active_agent, who owns the thread afterwards,
  • model calls: from a fresh CallCounter.

recursion_limit=12 stops a ping-pong of handoffs with an error instead of letting it spin.

support_org.py (add to the end of the file)
TURNS = ["How much was order A-100?", "And what about A-101?"]

if __name__ == "__main__":
    model = get_model()
    for pattern in ("subagents", "handoffs"):
        print(f"== {pattern}")
        org = build_org(model, pattern=pattern, checkpointer=InMemorySaver())
        total = 0
        for turn, question in enumerate(TURNS, 1):
            counter = CallCounter()
            config = {"configurable": {"thread_id": pattern}, "callbacks": [counter],
                      "recursion_limit": 12}
            seen = len(org.get_state(config).values.get("messages", []))
            result = org.invoke({"messages": [HumanMessage(question)]}, config=config)
            new_messages = result["messages"][seen:]
            route = [call["name"] for m in new_messages for call in getattr(m, "tool_calls", [])]
            total += counter.model_calls
            print(f"turn {turn}: {question}")
            print(f"  tools:       {', '.join(route) or 'none'}")
            print(f"  owner:       {result.get('active_agent', 'supervisor')}")
            print(f"  model calls: {counter.model_calls}")
            print(f"  answer:      {result['messages'][-1].text}")
        print(f"  total model calls for {pattern}: {total}\n")
7

Compare the two patterns

PowerShell
python support_org.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
== subagents
turn 1: How much was order A-100?
  tools:       ask_billing
  owner:       supervisor
  model calls: 4
  answer:      I'm forwarding your question to the billing specialist.

Billing brief (for the billing specialist):
- Customer asked: "How much was order A-100?"
- Please provide: total amount charged, currency, invoice number (if available), current payment/status (paid/refunded/pending), and a short breakdown of line items/discounts if available.
- Keep reply succinct and customer-facing.

Billing response:
- Total charged: 249.00 USD
- Current status: shipped
- No invoice number or line-item/discount breakdown available in records

If you'd like, I can ask billing to (a) resend an invoice if one exists, (b) provide a line-item breakdown, or (c) escalate to a human for deeper investigation. Which would you prefer?
turn 2: And what about A-101?
  tools:       ask_billing
  owner:       supervisor
  model calls: 4
  answer:      Billing update for order A-101:

- Total charged: 1,299.50 USD  
- Current status/payment: processing  
- No invoice number or line-item/discount breakdown available

Would you like me to ask billing to resend an invoice, provide a line-item breakdown, or escalate this to a human for further investigation?
  total model calls for subagents: 8

== handoffs
turn 1: How much was order A-100?
  tools:       transfer_to_billing, lookup_order
  owner:       billing
  model calls: 3
  answer:      Order A-100 total was 249.00 USD. The order status is shipped.
turn 2: And what about A-101?
  tools:       lookup_order
  owner:       billing
  model calls: 2
  answer:      Order A-101 total is 1,299.50 USD. The order status is processing.
  total model calls for handoffs: 5

Compare the two blocks:

  • Subagents: the tools line shows only ask_billing. The billing agent's own lookup_order call happened inside the tool and never reached the supervisor's history. Every turn costs the supervisor's calls plus the specialist's calls.
  • Handoffs: turn 1 shows transfer_to_billing and then lookup_order, because the billing agent works in the shared history. The owner becomes billing. On turn 2 entry sends the question straight to billing, skipping the supervisor, so it needs fewer calls.

Your counts may differ from the ones above. A real model may call a tool twice, answer without one, or write a longer brief. With the scripted model in the course tests the repeat request costs exactly 8 calls with subagents and 5 with handoffs. The trend is the lesson: handoffs are cheaper on follow-ups, subagents keep each context small.

8

Check your work

You are done when

  • Both patterns answer 249.00 USD for A-100 and 1,299.50 USD for A-101 (the formatting may vary).
  • In the subagents run the tools line shows ask_billing but never lookup_order.
  • In the handoffs run the owner is billing after turn 1, and turn 2 skips the supervisor.
  • You can explain in one sentence why the handoff pattern needs fewer model calls on turn 2.

Troubleshooting

GraphRecursionError: Recursion limit of 12 reached

Two agents kept transferring to each other. Check that each specialist gets handoffs only to the other targets, and read the error: it names the nodes it bounced between.

An error that a tool message has no matching tool call

The handoff Command must forward the requesting AIMessage before the ToolMessage, as in messages: [requesting_ai, ack].

The handoff goes nowhere, or 'node not found'

graph=Command.PARENT is missing. Without it LangGraph looks for the target inside the agent's own subgraph.

The supervisor answers the price itself

Check that the supervisor prompt says never answer from your own knowledge and that the specialists table is included in it.

401 Unauthorized or 'Access denied due to invalid subscription key'

The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.

404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'

AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.

429 Too Many Requests

The shared deployment is busy. Wait 30 seconds and run again.

ModuleNotFoundError: No module named 'model_setup' (or another package)

Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.

'python' is not recognized, or the wrong Python runs

Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.

Go further

  • Ask I want to talk to a human in both patterns and check that a ticket lands in HUMAN_QUEUE. In the handoffs pattern the answer comes from human_queue with no model call.
  • Give each specialist ModelCallLimitMiddleware(run_limit=4, exit_behavior="end") so one confused agent cannot burn the budget.
  • Add a third pattern, router: classify the request, Send one brief to each relevant specialist in parallel, and combine the answers. Compare its cost on a question that touches billing, SSO and refunds.
  • Turn on LangSmith tracing (LANGSMITH_TRACING=true) and compare how much of the transcript each specialist saw in the two patterns.

Stuck?

Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.