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.
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_schemaand docstrings shape the JSON schema the model sees - How
ToolRuntimeinjects 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
ToolErrorMiddlewareandModelCallLimitMiddlewarekeep a run safe
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.
python --versionpython3 --versionFrom 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.
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.
mkdir C:\labs\lab-02-support-agent
cd C:\labs\lab-02-support-agent
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-02-support-agent
cd ~/labs/lab-02-support-agent
python3 -m venv .venv
source .venv/bin/activateKeep 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:
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.
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.5langgraph 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.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd 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.
# 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 justhttps://<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/.
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.
"""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/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
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/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
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.
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.
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:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"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.
Write the support agent
Here is how one request flows through the agent:
- The customer message goes to the model.
- The model calls one or more tools. LangChain runs them and returns the results as
ToolMessages. - The loop repeats until the model has what it needs.
- The model finishes by filling in the
Resolutionschema, which you read fromresult["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_agentandAgentStatebuild the agent and its state.- The two middleware classes add error handling and a call limit.
ToolStrategyrequests a structured final answer.ToolException,ToolRuntimeandtoolare the tool building blocks.InMemorySaverstores the conversation between calls, andCommandlets a tool update the state.
"""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_modelBlock 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.
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:
SupportContextis read-only information about who the agent is serving. Your application passes it withcontext=on every call. The model never sees it as an argument, so it cannot invent or change the customer id.SupportStateextends the built-inAgentState(which holdsmessages) with three fields that tools can write.NotRequiredmeans they are absent until something sets them.
@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.
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-1000raisesToolException. 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.
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.
@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.
@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
Commandmust include theToolMessagefor 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.
@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.
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 aToolMessagewithstatus="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.
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 modelBlock 11Assemble the agent
Each argument adds one feature:
middleware:ModelCallLimitMiddleware(run_limit=8, exit_behavior="end")ends the run cleanly if the model loops, andToolErrorMiddlewareapplies the policy above.response_format=ToolStrategy(Resolution, ...)offersResolutionto the model as one more tool. When the model calls it, the arguments are validated and stored instructured_response.handle_errorsis sent back if validation fails, so the model can try again.state_schemaandcontext_schemaregister the two classes from earlier.checkpointersaves the state after each step, so later turns remember earlier ones.
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_calledlists the tools used in each turn.- The second turn asks about
AB-1000, which does not match theA-123format.lookup_orderraisesToolException, and thetool errorline shows that the error reached the model as a message instead of crashing the run. result.get("structured_response")is used instead ofresult[...], so a run that hits the call limit prints a clear message instead of aKeyError.- At the end,
agent.get_state(config)reads the saved thread and shows that the escalation fields survived.
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)')}")Run the agent
python support_agent.pypython3 support_agent.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
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-4471Read the four turns from top to bottom:
lookup_orderruns and the citations containorder:A-100.AB-1000is malformed. The tool raisedToolException,ToolErrorMiddlewareturned it into an error message (thetool errorline), and the model answered the customer instead of the run crashing.search_faqfinds the return window, andcheck_entitlementconfirms the gold plan includes free return shipping. The model knew the tier without asking, because it came from the context.escalate_to_humanruns,needs_human=True, and the thread state keepsticket_id=ESC-C-4471after the run.
The tool list for a turn can vary a little between runs, because the model decides which facts it needs.
Check your work
You are done when
- Every turn prints an
answerand ametadataline withintent,needs_humanandcitations. - The citations contain the bracketed ids the tools returned, for example
order:A-100andfaq:returns-window. - The last turn shows
needs_human=True, and the thread state showsescalated=True ticket_id=ESC-C-4471. check_entitlementnever 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()andcheck_entitlement.tool_call_schema.model_json_schema(). The second one has no properties at all. - Give
search_faqa bound withtop_k: int = Field(default=2, ge=1, le=4)via anargs_schema, and check that atop_k=99call 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.