Agentic AI Engineering with LangChain / Lab 03
Module 03 · Middleware

Lab 03 · The reliability kit

Make a support agent production-ready with a middleware list: PII redaction, your own audit trail, a tool budget, retries and a fallback model.

45 minIntermediateAzure OpenAIAgentMiddlewarewrap_tool_callPIIMiddlewareToolCallLimitMiddlewareModelRetryMiddlewareModelFallbackMiddlewarereducers

What you will build

The Northwind order agent from Lab 00, made safe to put in front of customers without touching its tools or prompt. Everything is added through middleware: four built-in pieces plus one you write yourself.

  • Email addresses are redacted before the model ever sees them.
  • Every tool call is recorded in an audit log in the agent state.
  • A tool budget stops runaway loops.
  • Failed model calls are retried, and if the retries run out a fallback model takes over.

What you will learn

  • What middleware is and where its hooks run in the agent loop
  • Why the order of the middleware list matters: wrapper hooks nest like m1(m2(m3(model)))
  • How to write your own middleware with wrap_tool_call and a custom state field
  • Why a state list needs an operator.add reducer when tools run in parallel
  • Why ModelRetryMiddleware needs on_failure="error" for the fallback to fire
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-03-reliability-kit
cd C:\labs\lab-03-reliability-kit
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-03-reliability-kit/ ├── .env ├── model_setup.py ├── reliability_kit.py └── requirements.txt
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 is the engine under create_agent; the lab imports Command from it directly.

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 reliability kit

Middleware wraps the agent loop. Each middleware can run code before and after the model call, or wrap every model call and every tool call. You add behavior such as redaction, logging or retries in one place, without changing the tools or the prompt.

The finished stack, from outermost to innermost:

  1. PIIMiddleware redacts email addresses.
  2. AuditMiddleware (yours) records every tool call.
  3. ToolCallLimitMiddleware stops tool loops.
  4. ModelFallbackMiddleware switches to a backup model.
  5. ModelRetryMiddleware retries the model call.

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

Block 1Imports

  • AgentMiddleware is the base class for your own middleware.
  • The other middleware classes are built in.
  • ToolCallRequest describes one tool call that is about to run.
  • Command lets a hook write to the agent state.
reliability_kit.py (add to the end of the file)
"""Lab 03: a reliability kit for the Northwind support agent.

PII redaction, an audit trail of our own, a tool budget, a fallback model and retries,
composed in the order that works.

Run:  python reliability_kit.py
"""

import operator
from typing import Annotated, Any, Awaitable, Callable, NotRequired

from langchain.agents import create_agent
from langchain.agents.middleware import (
    AgentMiddleware,
    AgentState,
    ModelFallbackMiddleware,
    ModelRetryMiddleware,
    PIIMiddleware,
    ToolCallLimitMiddleware,
    ToolCallRequest,
)
from langchain.messages import ToolMessage
from langchain.tools import tool
from langchain_core.language_models import BaseChatModel
from langgraph.types import Command

from model_setup import get_model

Block 2Two simple tools

The same two tools as Lab 00, with fixed answers. The point of this lab is the middleware around them, not the tools.

reliability_kit.py (add to the end of the file)
@tool
def get_order_status(order_id: str) -> str:
    """Look up the shipping status of an order, such as A-100."""
    return f"Order {order_id.upper()} shipped via DHL, arriving 2026-09-15."


@tool
def get_invoice_total(order_id: str) -> str:
    """Return the amount charged for an order. Use for questions about price or total."""
    return f"Order {order_id.upper()} total: 249.00 USD."

Block 3State with an audit log

AuditState adds an audit_log list to the built-in AgentState.

  • Annotated[..., operator.add] sets a reducer: new entries are appended to the list instead of replacing it. When the model calls two tools in parallel, both write audit_log in the same step. Without the reducer one of the writes would be lost.
  • NotRequired lets you start a run with only messages. Without it, invoke({"messages": [...]}) fails because audit_log is missing.
reliability_kit.py (add to the end of the file)
class AuditState(AgentState):
    """AgentState plus an audit trail. `operator.add` so parallel tool calls both append."""

    audit_log: NotRequired[Annotated[list[dict[str, Any]], operator.add]]

Block 4One audit entry

A small helper that builds one log line from the tool call (name and arguments) and its result (status is "success" or "error").

reliability_kit.py (add to the end of the file)
def audit_entry(request: ToolCallRequest, result: ToolMessage) -> dict[str, Any]:
    """One line of the audit trail for an executed tool call."""
    name, args = request.tool_call["name"], request.tool_call["args"]
    return {"tool": name, "args": args, "status": result.status}

Block 5Your own middleware

wrap_tool_call wraps every tool call. It calls handler(request) to run the tool (and any inner middleware), then returns a Command that updates the state.

  • The Command must include the ToolMessage under "messages". Leave it out and the model never sees the tool result.
  • state_schema = AuditState registers the new state field with the agent.
  • awrap_tool_call is the same logic for async runs (ainvoke). Sync and async hooks do not fall back to each other, so without it an async run would skip the audit.
reliability_kit.py (add to the end of the file)
class AuditMiddleware(AgentMiddleware):
    """Records every executed tool call in agent state.

    A `Command` is how a wrapper hook makes a persistent update; the ToolMessage has to
    travel inside it, or the model never sees the tool result.
    """

    state_schema = AuditState

    def wrap_tool_call(
        self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage]
    ) -> Command:
        """Run the tool, then record it (sync path: invoke)."""
        result = handler(request)
        return Command(update={"audit_log": [audit_entry(request, result)], "messages": [result]})

    async def awrap_tool_call(
        self,
        request: ToolCallRequest,
        handler: Callable[[ToolCallRequest], Awaitable[ToolMessage]],
    ) -> Command:
        """Run the tool, then record it (async path: ainvoke)."""
        result = await handler(request)
        return Command(update={"audit_log": [audit_entry(request, result)], "messages": [result]})

Block 6The system prompt

Same prompt as Lab 00: answer about orders and always use a tool for facts.

reliability_kit.py (add to the end of the file)
SYSTEM_PROMPT = (
    "You are the Northwind support assistant. Answer briefly and only about orders. "
    "Never guess a status or an amount: always call the matching tool."
)

Block 7The middleware stack

The first entry in the list is the outermost layer. Wrapper hooks nest like pii(audit(limit(fallback(retry(model))))), so the order is a design decision:

  • PIIMiddleware("email", strategy="redact") comes first, so the raw address never reaches the model, the tools, the fallback or the logs.
  • ToolCallLimitMiddleware(run_limit=4, exit_behavior="end") ends the run after four tool calls.
  • ModelFallbackMiddleware sits outside ModelRetryMiddleware. The primary model is retried first, and only when all retries fail does the fallback take over.
  • on_failure="error" makes the retry middleware re-raise the last error. The default, "continue", turns the failure into a friendly AIMessage, so the error never reaches the fallback and the fallback never fires.

About the fallback model: in class you have one deployment, so the fallback is the same deployment as the primary. The fallback only runs when the primary fails, so in a normal run you will not notice it. In production you point it at a different deployment or provider, for example get_model(deployment="gpt-4.1-mini").

reliability_kit.py (add to the end of the file)
def build_agent(model: BaseChatModel | None = None, fallback: BaseChatModel | None = None) -> Any:
    """Build the agent with the reliability middleware stack."""
    return create_agent(
        model=model or get_model(),
        tools=[get_order_status, get_invoice_total],
        system_prompt=SYSTEM_PROMPT,
        middleware=[
            # Outermost: the raw email never reaches the model, the tools or the trace.
            PIIMiddleware("email", strategy="redact"),
            AuditMiddleware(),
            ToolCallLimitMiddleware(run_limit=4, exit_behavior="end"),
            # Fallback sits OUTSIDE retry, so the fallback is tried only once the retries
            # are spent, and retry must re-raise, or the fallback never fires.
            ModelFallbackMiddleware(fallback or get_model()),
            ModelRetryMiddleware(max_retries=2, on_failure="error"),
        ],
    )

Block 8Run two scenarios

ask prints the stored question, each audit entry and the answer. It uses result.get("audit_log", []) so a run without tool calls prints nothing instead of failing.

  • Normal run: the primary model answers.
  • Broken primary: the primary points at a deployment name that does not exist, so every call fails. The retry middleware tries three times, gives up and re-raises; the fallback then answers with your real deployment.
reliability_kit.py (add to the end of the file)
def ask(agent: Any, question: str) -> None:
    """Run one question and print what the middleware did."""
    result = agent.invoke({"messages": [{"role": "user", "content": question}]})
    print("stored question:", result["messages"][0].content)
    for entry in result.get("audit_log", []):
        print("audit log entry:", entry)
    print("answer:", result["messages"][-1].text)


if __name__ == "__main__":
    question = "I am ada@northwind.example - where is A-100 and how much was it?"

    print("=== Normal run: the primary model answers")
    ask(build_agent(), question)

    # A deployment name that does not exist makes every primary call fail.
    print("\n=== Broken primary: retries run out, then the fallback answers")
    ask(build_agent(model=get_model(deployment="no-such-deployment")), question)
7

Run the reliability kit

PowerShell
python reliability_kit.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
=== Normal run: the primary model answers
stored question: I am [REDACTED_EMAIL] - where is A-100 and how much was it?
audit log entry: {'tool': 'get_order_status', 'args': {'order_id': 'A-100'}, 'status': 'success'}
audit log entry: {'tool': 'get_invoice_total', 'args': {'order_id': 'A-100'}, 'status': 'success'}
answer: Order A-100 shipped via DHL and is scheduled to arrive 2026-09-15. Total charged: 249.00 USD.

=== Broken primary: retries run out, then the fallback answers
stored question: I am [REDACTED_EMAIL] - where is A-100 and how much was it?
audit log entry: {'tool': 'get_order_status', 'args': {'order_id': 'A-100'}, 'status': 'success'}
audit log entry: {'tool': 'get_invoice_total', 'args': {'order_id': 'A-100'}, 'status': 'success'}
answer: Order A-100 shipped via DHL — arriving 2026-09-15. Total charged: 249.00 USD.
  • stored question shows [REDACTED_EMAIL] instead of the address. The redaction happened before the model call, so the address is not in the state either.
  • audit log entry lines show one entry per tool call, written by your middleware.
  • In the Broken primary run, the answer still arrives. The retries failed against the missing deployment, which takes a few seconds, and then the fallback model answered. Your code did not need a single try/except.
8

Check your work

You are done when

  • The stored question contains [REDACTED_EMAIL], not ada@northwind.example.
  • The audit log has one entry per tool call, each with tool, args and status.
  • The broken-primary run still prints an answer, thanks to the fallback.
  • You can explain why ModelFallbackMiddleware comes before ModelRetryMiddleware in the list.

Troubleshooting

The broken-primary run fails with NotFoundError instead of answering

Check that ModelRetryMiddleware has on_failure="error" and comes after ModelFallbackMiddleware in the list. Otherwise the error never reaches the fallback.

The audit log is empty but the answer is correct

Make sure wrap_tool_call returns the Command and that AuditMiddleware has state_schema = AuditState.

The model says it cannot see the tool result

The Command in your middleware is missing "messages": [result].

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

  • Swap PIIMiddleware to the end of the list and run again. Where does the raw email appear now?
  • Change on_failure="error" to "continue" and rerun the broken-primary scenario. The fallback never fires.
  • Call asyncio.run(agent.ainvoke(...)) and check the audit log still fills, thanks to awrap_tool_call.
  • Write a before_model middleware that prints how many messages are sent on each model call.

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.