Agentic AI Engineering with LangChain / Lab 02
Module 02 · Tools and create_agent

Lab 02 · Northwind support agent v0

Build the support agent the rest of the course extends: four tools, custom state, a per-run context, a typed Resolution answer and a tool-error policy.

60 minIntermediateAzure OpenAI@toolargs_schemaToolRuntimeCommandToolStrategyToolErrorMiddlewareModelCallLimitMiddlewarecontext_schema

What you will build

A support agent that answers order and policy questions for a signed-in customer and hands over to a human when asked. Every run ends with a typed Resolution object that a CRM could store directly, including the ids of the facts it used.

Along the way you use the most important tool features of LangChain: argument schemas, hidden runtime context, tools that update the agent state, error handling and a safety limit on model calls.

What you will learn

  • How an args_schema and docstrings shape the JSON schema the model sees
  • How ToolRuntime injects per-run context (the customer) that the model can never fake
  • How a tool returns Command(update=...) to write custom fields into the agent state
  • How ToolStrategy(Resolution) turns the final answer into a validated Pydantic object
  • The difference between a business miss (return a string) and a bad call (raise ToolException)
  • How ToolErrorMiddleware and ModelCallLimitMiddleware keep a run safe
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-02-support-agent
cd C:\labs\lab-02-support-agent
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-02-support-agent/ ├── .env ├── model_setup.py ├── requirements.txt └── support_agent.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
langgraph-checkpoint==4.2.0
pydantic==2.13.5

langgraph is the engine under create_agent, and langgraph-checkpoint provides the in-memory checkpointer that keeps the conversation between turns. Both are installed with LangChain anyway; pinning them keeps your versions identical to the tested ones.

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 agent

Here is how one request flows through the agent:

  1. The customer message goes to the model.
  2. The model calls one or more tools. LangChain runs them and returns the results as ToolMessages.
  3. The loop repeats until the model has what it needs.
  4. The model finishes by filling in the Resolution schema, which you read from result["structured_response"].

Next to the messages, the agent keeps state (custom fields such as escalated) and receives a context (who the customer is) that your application passes in on every call.

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

Block 1Imports

  • create_agent and AgentState build the agent and its state.
  • The two middleware classes add error handling and a call limit.
  • ToolStrategy requests a structured final answer.
  • ToolException, ToolRuntime and tool are the tool building blocks.
  • InMemorySaver stores the conversation between calls, and Command lets a tool update the state.
support_agent.py (add to the end of the file)
"""Lab 02: Northwind support agent v0.

Four tools, a custom state, a per-run context and a typed Resolution answer.

Run:  python support_agent.py
"""

import re
from dataclasses import dataclass
from typing import Any, Literal, NotRequired

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import (
    ModelCallLimitMiddleware,
    ToolCallRequest,
    ToolErrorMiddleware,
)
from langchain.agents.structured_output import ToolStrategy
from langchain.messages import ToolMessage
from langchain.tools import ToolException, ToolRuntime, tool
from langchain_core.language_models import BaseChatModel
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from pydantic import BaseModel, Field

from model_setup import get_model

Block 2Pretend back-end data

Three orders, four help-centre articles and the benefits of each support tier. ORDER_ID_RE describes a valid order id: one capital letter, a dash and three digits, such as A-100.

support_agent.py (add to the end of the file)
ORDERS = {
    "A-100": {"status": "shipped via DHL", "eta": "2026-09-15", "total": 249.00,
              "items": "Northwind Router X2"},
    "A-101": {"status": "processing", "eta": "2026-09-18", "total": 1299.50,
              "items": "Northwind Edge Gateway"},
    "A-102": {"status": "delivered by UPS", "eta": "2026-09-02", "total": 89.99,
              "items": "Northwind Sensor Pack"},
}
FAQ = {
    "returns-window": "Hardware can be returned within 30 days of delivery for a full refund.",
    "shipping-times": "Standard shipping is 3-5 business days; express is next business day.",
    "warranty": "All Northwind hardware carries a 24-month limited warranty.",
    "cancellation": "Orders can be cancelled free of charge while they are still processing.",
}
TIER_ENTITLEMENTS = {
    "standard": "email support, next-business-day response, paid return shipping",
    "gold": "24/5 support, 4-hour response, free return shipping, advance replacement",
    "enterprise": "24/7 support, 1-hour response, free returns, a named CSM",
}
ORDER_ID_RE = re.compile(r"^[A-Z]-\d{3}$")

Block 3Context and state

These two classes look similar but play different roles:

  • SupportContext is read-only information about who the agent is serving. Your application passes it with context= on every call. The model never sees it as an argument, so it cannot invent or change the customer id.
  • SupportState extends the built-in AgentState (which holds messages) with three fields that tools can write. NotRequired means they are absent until something sets them.
support_agent.py (add to the end of the file)
@dataclass
class SupportContext:
    """Per-run, read-only facts about who we are serving. Supplied by the application."""

    customer_id: str
    tier: Literal["standard", "gold", "enterprise"] = "standard"
    locale: str = "en-US"


class SupportState(AgentState):
    """Agent state plus the escalation facts our tools write back into the loop."""

    escalated: NotRequired[bool]
    escalation_reason: NotRequired[str]
    ticket_id: NotRequired[str]

Block 4The structured answer

Resolution is the typed result of every run. The Field(description=...) texts are the only documentation the model has for each field, so write them carefully.

citations collects ids such as order:A-100. The tools end their results with those ids in square brackets, which gives the model something concrete to cite.

support_agent.py (add to the end of the file)
class Resolution(BaseModel):
    """The structured outcome of a support conversation, for the CRM and for evals."""

    answer: str = Field(description="The customer-facing reply, 1-3 sentences.")
    intent: Literal["order_status", "faq", "escalation", "other"] = Field(
        description="What the customer actually wanted."
    )
    order_id: str | None = Field(default=None, description="Order id, or null if none applies.")
    needs_human: bool = Field(default=False, description="True when a human must take over.")
    citations: list[str] = Field(
        default_factory=list,
        description="Ids of the facts used, e.g. 'order:A-100' or 'faq:returns-window'.",
    )

Block 5Tool 1: look up an order

args_schema=OrderLookup gives the tool an explicit argument schema: order_id is required, include_items defaults to false.

Notice the two failure paths:

  • A malformed id such as AB-1000 raises ToolException. That means you called me wrong, fix the arguments.
  • A well-formed id that does not exist returns a normal string. Not finding an order is a valid business answer, not an error.

Tip: print(lookup_order.tool_call_schema.model_json_schema()) shows exactly what the model receives.

support_agent.py (add to the end of the file)
class OrderLookup(BaseModel):
    """Arguments for looking up an order."""

    order_id: str = Field(description="Order id in the form 'A-123'.")
    include_items: bool = Field(default=False, description="Also list the ordered items.")


@tool("lookup_order", args_schema=OrderLookup)
def lookup_order(order_id: str, include_items: bool = False) -> str:
    """Look up a Northwind order. Use whenever the customer mentions an order id."""
    order_id = order_id.strip().upper()
    if not ORDER_ID_RE.match(order_id):
        raise ToolException(f"{order_id!r} is not a valid order id; expected the form 'A-123'.")
    order = ORDERS.get(order_id)
    if order is None:
        # A business miss is a normal result, not an exception.
        return f"No order found with id {order_id}."
    items = f" Items: {order['items']}." if include_items else ""
    return (
        f"Order {order_id} is {order['status']}; ETA {order['eta']}; "
        f"total {order['total']:.2f} USD.{items} [order:{order_id}]"
    )

Block 6Tool 2: search the help centre

parse_docstring=True reads the Google-style Args: section and turns each line into the description of that argument. The search itself is a simple keyword match, enough to show the idea.

support_agent.py (add to the end of the file)
@tool(parse_docstring=True)
def search_faq(query: str, top_k: int = 2) -> str:
    """Search the Northwind help centre for policy answers, not for a specific order.

    Args:
        query: The customer's question, in their own words.
        top_k: How many help-centre passages to return.
    """
    words = re.findall(r"[a-z]{4,}", query.lower())
    hits = [
        f"{text} [faq:{key}]"
        for key, text in FAQ.items()
        if any(word in f"{key} {text}".lower() for word in words)
    ]
    return "\n".join(hits[:top_k]) or "No help-centre article matched that question."

Block 7Tool 3: check the customer's plan

This tool has no arguments the model can fill. The runtime parameter is a ToolRuntime, which LangChain injects and hides from the model. Through it the tool reads runtime.context, the SupportContext your application passed.

This is how you keep identity safe: the customer id comes from your login system, never from text the model generated.

support_agent.py (add to the end of the file)
@tool
def check_entitlement(runtime: ToolRuntime[SupportContext, SupportState]) -> str:
    """Report the current customer's plan and what it includes. Takes no arguments."""
    ctx = runtime.context
    return (
        f"Customer {ctx.customer_id} is on the {ctx.tier} plan: "
        f"{TIER_ENTITLEMENTS[ctx.tier]}. [tier:{ctx.tier}]"
    )

Block 8Tool 4: escalate to a human

Instead of a string, this tool returns a Command(update={...}). The update writes escalated, escalation_reason and ticket_id into the agent state.

  • A Command must include the ToolMessage for this tool call. If you forget it, the error shows up on the next model call, which is confusing to debug.
  • The message says the work is finished and tells the model not to call any other tool. A plain "Escalated as ticket X" reads like progress, and the model keeps working.
  • The ticket id is built from the customer id, so escalating twice gives the same ticket.
support_agent.py (add to the end of the file)
@tool
def escalate_to_human(reason: str, runtime: ToolRuntime[SupportContext, SupportState]) -> Command:
    """Open a human ticket. Use when the customer asks for a person or a tool failure blocks you."""
    # One ticket per customer, so calling this twice cannot open a second one.
    ticket_id = f"ESC-{runtime.context.customer_id}"
    return Command(
        update={
            "escalated": True,
            "escalation_reason": reason,
            "ticket_id": ticket_id,
            # A tool result is a prompt: a final tool has to say that the work is over.
            "messages": [
                ToolMessage(
                    f"Escalated as ticket {ticket_id} ({reason}). This is the final action for "
                    "this request: do not call any other tool. Emit the Resolution now, with "
                    "needs_human=true and the ticket id in the answer.",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )

Block 9Tools list and system prompt

The prompt maps each kind of question to its tool, forbids invented facts, and asks the model to copy the [bracketed] ids into citations.

support_agent.py (add to the end of the file)
TOOLS = [lookup_order, search_faq, check_entitlement, escalate_to_human]

SYSTEM_PROMPT = (
    "You are the Northwind Cloud support agent. Ground every factual claim in a tool result: "
    "lookup_order for orders, search_faq for policy, check_entitlement before promising "
    "anything that depends on the customer's plan. Never invent order ids, dates or totals. "
    "Escalate when the customer asks for a human, and as soon as escalate_to_human returns, "
    "stop gathering facts. Finish every request with the Resolution schema, copying every "
    "[bracketed] id you relied on into its citations."
)

Block 10The tool-error policy

ToolErrorMiddleware calls this function whenever a tool raises.

  • For a ToolException, it returns a message. The model receives it as a ToolMessage with status="error", and can fix its arguments and retry.
  • For anything else it returns None, so the exception stops the run. An unexpected bug should fail loudly, not be shown to the model.
support_agent.py (add to the end of the file)
def on_tool_error(exc: Exception, request: ToolCallRequest) -> str | None:
    """Surface bad tool arguments to the model; let unexpected bugs halt the run."""
    if isinstance(exc, ToolException):
        return (
            f"Tool '{request.tool_call['name']}' rejected the call: {exc} "
            "Correct the arguments and call it again."
        )
    return None  # propagate: never send an unknown internal error to the model

Block 11Assemble the agent

Each argument adds one feature:

  • middleware: ModelCallLimitMiddleware(run_limit=8, exit_behavior="end") ends the run cleanly if the model loops, and ToolErrorMiddleware applies the policy above.
  • response_format=ToolStrategy(Resolution, ...) offers Resolution to the model as one more tool. When the model calls it, the arguments are validated and stored in structured_response. handle_errors is sent back if validation fails, so the model can try again.
  • state_schema and context_schema register the two classes from earlier.
  • checkpointer saves the state after each step, so later turns remember earlier ones.
support_agent.py (add to the end of the file)
def build_agent(
    model: BaseChatModel | None = None, checkpointer: BaseCheckpointSaver | None = None
) -> Any:
    """Build the support agent. Pass a model to override the Azure OpenAI default."""
    return create_agent(
        model=model or get_model(),
        tools=TOOLS,
        system_prompt=SYSTEM_PROMPT,
        middleware=[
            # The backstop: end the run cleanly if the model never converges.
            ModelCallLimitMiddleware(run_limit=8, exit_behavior="end"),
            ToolErrorMiddleware(on_tool_error),
        ],
        response_format=ToolStrategy(
            Resolution,
            tool_message_content="Resolution recorded.",
            handle_errors=(
                "That Resolution was invalid. Re-emit it with every required field: "
                "answer, intent, order_id (or null), needs_human, citations."
            ),
        ),
        state_schema=SupportState,
        context_schema=SupportContext,
        checkpointer=checkpointer,
        name="northwind-support-v0",
    )

Block 12A four-turn conversation

All four turns use the same thread_id, so they form one conversation. context= passes the signed-in customer on every call.

  • tools_called lists the tools used in each turn.
  • The second turn asks about AB-1000, which does not match the A-123 format. lookup_order raises ToolException, and the tool error line shows that the error reached the model as a message instead of crashing the run.
  • result.get("structured_response") is used instead of result[...], so a run that hits the call limit prints a clear message instead of a KeyError.
  • At the end, agent.get_state(config) reads the saved thread and shows that the escalation fields survived.
support_agent.py (add to the end of the file)
def tools_called(messages: list[Any]) -> list[str]:
    """Names of the real tools the model called (the Resolution tool is left out)."""
    names = [call["name"] for m in messages for call in getattr(m, "tool_calls", [])]
    return [name for name in names if name != Resolution.__name__]


if __name__ == "__main__":
    agent = build_agent(checkpointer=InMemorySaver())
    config = {"configurable": {"thread_id": "northwind-lab-02"}}
    context = SupportContext(customer_id="C-4471", tier="gold")

    for turn in [
        "Where is order A-100 and what did it cost?",
        "And what about my other order, AB-1000?",
        "It arrived damaged. What is your return window, and do I pay return shipping?",
        "That is not good enough. Put me through to a person.",
    ]:
        before = len(agent.get_state(config).values.get("messages", []))
        result = agent.invoke(
            {"messages": [{"role": "user", "content": turn}]}, config=config, context=context
        )
        new_messages = result["messages"][before:]
        errors = [m.name for m in new_messages if getattr(m, "status", None) == "error"]
        print(f"\ncustomer  : {turn}")
        print(f"tools     : {', '.join(tools_called(new_messages)) or 'none'}")
        if errors:
            print(f"tool error: {', '.join(errors)} (the model received the error message instead of a crash)")
        resolution = result.get("structured_response")
        if resolution is None:
            print("answer    : (no Resolution: the run hit the model-call limit)")
            continue
        print(f"answer    : {resolution.answer}")
        print(f"metadata  : intent={resolution.intent} needs_human={resolution.needs_human} "
              f"citations={resolution.citations}")

    values = agent.get_state(config).values
    print(f"\nthread state: escalated={values.get('escalated', False)} "
          f"ticket_id={values.get('ticket_id', '(no escalation happened)')}")
7

Run the agent

PowerShell
python support_agent.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

customer  : Where is order A-100 and what did it cost?
tools     : lookup_order
answer    : Order A-100 was shipped via DHL with an ETA of 2026-09-15; the total cost was 249.00 USD. The order includes: Northwind Router X2.
metadata  : intent=order_status needs_human=False citations=['order:A-100']

customer  : And what about my other order, AB-1000?
tools     : lookup_order
tool error: lookup_order (the model received the error message instead of a crash)
answer    : I couldn’t look up “AB-1000” because the order ID must be in the form “A-123” and the lookup rejected that ID; please confirm the correct order ID (for example “A-123”) and I’ll check its status right away.
metadata  : intent=order_status needs_human=False citations=['order:A-100']

customer  : It arrived damaged. What is your return window, and do I pay return shipping?
tools     : search_faq, check_entitlement
answer    : Sorry this arrived damaged. Hardware can be returned within 30 days of delivery for a full refund, and since your account is on the Gold plan you have free return shipping and an advance replacement—would you like me to start the return for order A-100?
metadata  : intent=faq needs_human=False citations=['faq:returns-window', 'tier:gold', 'order:A-100']

customer  : That is not good enough. Put me through to a person.
tools     : escalate_to_human
answer    : I’ve escalated your damaged order A-100 to our specialist team and opened ticket ESC-C-4471; a human agent will take over (your Gold plan includes a 4-hour response).
metadata  : intent=escalation needs_human=True citations=['order:A-100', 'faq:returns-window', 'tier:gold', 'ESC-C-4471']

thread state: escalated=True ticket_id=ESC-C-4471

Read the four turns from top to bottom:

  1. lookup_order runs and the citations contain order:A-100.
  2. AB-1000 is malformed. The tool raised ToolException, ToolErrorMiddleware turned it into an error message (the tool error line), and the model answered the customer instead of the run crashing.
  3. search_faq finds the return window, and check_entitlement confirms the gold plan includes free return shipping. The model knew the tier without asking, because it came from the context.
  4. escalate_to_human runs, needs_human=True, and the thread state keeps ticket_id=ESC-C-4471 after the run.

The tool list for a turn can vary a little between runs, because the model decides which facts it needs.

8

Check your work

You are done when

  • Every turn prints an answer and a metadata line with intent, needs_human and citations.
  • The citations contain the bracketed ids the tools returned, for example order:A-100 and faq:returns-window.
  • The last turn shows needs_human=True, and the thread state shows escalated=True ticket_id=ESC-C-4471.
  • check_entitlement never asks the model for a customer id.

Troubleshooting

AttributeError: 'NoneType' object has no attribute 'customer_id'

The agent was called without context=. runtime.context is None then. Pass context=SupportContext(...) on every invoke.

The run ends with no Resolution

The model used all eight model calls allowed by ModelCallLimitMiddleware. Check the tool docstrings and the system prompt, then run again.

The model keeps calling tools after escalating

Check the ToolMessage text in escalate_to_human. It must say the work is finished and that no other tool should be called.

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

  • Print lookup_order.tool_call_schema.model_json_schema() and check_entitlement.tool_call_schema.model_json_schema(). The second one has no properties at all.
  • Give search_faq a bound with top_k: int = Field(default=2, ge=1, le=4) via an args_schema, and check that a top_k=99 call never reaches your function.
  • Lower ModelCallLimitMiddleware(run_limit=...) to 2 and watch a normal question end on the limit instead of an answer.
  • Use ToolStrategy(Resolution | Escalation) with a second schema for I could not finish this, and let the system prompt choose between them.

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.