Assignment 2 · Agents & prebuilt middleware

← Learning Hub · GreenPlate fundamentals

NovaBank · Solutions

Interactive answer key for Assignment 2. Domain is a personal banking assistant — prove the concepts, don’t rename Cinebot.

Part A · conceptual Part B · coding Part C · capstone Built-ins only No custom middleware
How this relates. This page is the NovaBank answer key for Assignment 2 (agents and prebuilt middleware). The GreenPlate fundamentals key (landscape → tools) is a different assignment — see the LangChain Fundamentals study guide. Keep the NovaBank notebook blank until you have tried it; this page is the spoiler.
00 · How to use this page

Attempt, then reveal

Part A answers sit behind Show answer. Part B/C include one verified working solution each — not the only correct shape, especially the capstone. Simulators on this page are teaching models; they do not call an LLM.

Scope lock
Only create_agent, thread_id, context_schema, and built-in middleware. No @before_model, no subclassing AgentMiddleware.
Why NovaBank
A new domain so you cannot copy-paste Cinebot / GreenPlate / TripMate with renamed variables.
Pass bar
Explain the loop, persist with a checkpointer, gate a transfer, redact NB-########, compose retry + error, then stack six prebuilt pieces.

Agent loop (mechanical)

Click through once before Q1. This is the same loop middleware wraps.

1
User message in
“Transfer ₹50,000 to NB-20000002.” Appended to messages.
2
Model call
before_model → nested wrap_model_call → LLM sees tools + history.
3
Tool request
AIMessage with .tool_calls. Nothing has executed yet. HITL can interrupt here (after_model).
4
Tool execution
Nested wrap_tool_call (retry, error, emulator). Result becomes a ToolMessage.
5
Loop or finish
More tool_calls → back to step 2. No tool_calls → final AIMessage (and optional structured_response).
User message lands in state. Middleware has not fired yet.
Part A · Conceptual

Answers Q1–Q15

A1 · Agents

Q1 Describe the agent loop when a tool call is involved

The user message is appended to state. The model is called with the tool schemas. If it returns tool_calls, those are requests, not results. Each call is executed (unless HITL interrupts). A ToolMessage is appended. The model is called again with that result. Repeat until the model returns a plain AIMessage (or a structured finish via response_format). Middleware wraps the model call, the tool call, and the once-per-invoke agent boundaries — it does not replace this loop.

Q2 thread_id alone doesn’t persist a conversation

You also need a checkpointer (InMemorySaver in class; Postgres in production) passed into create_agent(..., checkpointer=...). thread_id is only the key the checkpointer uses. Forget the checkpointer and every .invoke() is amnesiac — the id is ignored. HITL also breaks: there is nowhere to save the paused graph, so you cannot resume an approve/edit/reject decision.

Q3 thread_id vs context
LivesNovaBank example
thread_id + checkpointerThis conversation, mutable across turnsCustomer said “call me Meera” two messages ago
context / context_schemaThis invoke only — pass fresh every callaccount_tier="premium" for this HTTP request

Swap bug: put tier in the thread. A later invoke on the same thread still looks premium after the customer’s session should have been standard — a transfer tool that was gated “for this request” stays visible. Put the name in context instead of the thread: the next .invoke() forgets “Meera” even with the same thread_id.

Q4 Three practical stream_mode options
ModeBest for
messagesChat UI token stream (typing effect) from the model node.
updatesProgress: which node just wrote (model vs tools), including HITL pauses.
customYour own signals from a tool via get_stream_writer (e.g. “contacting core banking…”).

You can pass a list, e.g. stream_mode=["messages", "updates"]. values exists too (full state each step) but the three you actually reach for in this course are the ones above.

Q5 Why name= costs nothing now but matters later

It is just a string on create_agent(name="novabank-assistant") — no extra tokens, no extra calls. Later it becomes the graph/node name in LangSmith traces, subgraph streaming namespaces, and multi-agent routing. Unnamed compiled graphs show up as anonymous blobs when you finally nest this agent inside a larger graph.

A2 · Prebuilt middleware core

Q6 Hook order for before_*, after_*, wrap_*

Given middleware=[m1, m2, m3]:

  • before_* runs first → last (m1, m2, m3).
  • wrap_* nests like function calls: m1 is outermost, m3 is closest to the model/tool.
  • after_* runs last → first (m3, m2, m1).

before_* and after_* differ because after-hooks unwind like a stack (onion). That is why ToolError must sit outside ToolRetry: the inner wrap sees the raw exception first; the outer wrap sanitizes whatever is still raised.

Q7 Four HITL decisions on transfer_funds
DecisionNovaBank situation
approveMeera asked to send ₹2,000 to her own joint account. Amount and destination match. Let it run.
editModel parsed “fifty thousand” as 50000 but she meant ₹5,000. Change amount before the tool fires.
rejectDestination looks like a mule account / she says “wait, cancel.” Tool must not run; feed that back as an error ToolMessage.
respondThe “tool” is really the human (e.g. ask_customer for a 2FA code). Your reply is the successful tool result. Do not use respond to deny a transfer — that looks like success.
Q8 Summarization vs context-editing for a chatty NovaBank thread

SummarizationMiddleware. The problem is a long, chatty conversation with a handful of simple tools — history tokens, not huge tool dumps. Summarization compresses old turns while keeping recent messages. ContextEditingMiddleware + ClearToolUsesEdit is for bloated tool results (seat maps, PDF extracts). Clearing tool uses on a chatty “what’s my balance / thanks / also…” thread barely helps.

Q9 Three distinct retry mechanisms
MechanismRetries whatNovaBank scenario
ToolRetryMiddlewareThe same tool call (backoff)Core-banking check_live_balance throws ConnectionError 50% of the time.
ModelRetryMiddlewareThe same model HTTP callProvider 429 / timeout on gpt-5-mini. Don’t swap models; wait and retry.
Agent-loop retry (error ToolMessage / validation)A new model decisionTransfer amount fails a Pydantic le=100000 constraint, or ToolError returns a safe message — the model tries different args. Not the same call retried.

ModelFallbackMiddleware is related but different in kind: it switches models, it does not retry the same request.

Q10 Tight tool limit, looser model limit

ToolCallLimitMiddleware(tool_name="transfer_funds", run_limit=1, thread_limit=2) — a transfer is consequential; one attempt per question, two per session. ModelCallLimitMiddleware(run_limit=12, thread_limit=40) stays looser because the same turn may need several model calls (clarify beneficiary, confirm, then answer). Tight model limits would kill the conversation before the transfer cap ever mattered.

A3 · Advanced

Q11 ToolError + ToolRetry composition

Correct list: [ToolErrorMiddleware(...), ToolRetryMiddleware(..., on_failure="error")].

First middleware is the outer wrap. Inner ToolRetry actually retries. When retries are exhausted, on_failure="error" re-raises so the outer ToolError can turn the exception into a safe ToolMessage. Default ToolRetry on_failure="continue" would swallow the error as a raw-ish message and ToolError would never see it.

Backwards: ToolRetry outer / ToolError inner. Inner ToolError converts the first exception into a ToolMessage immediately — there is nothing left to retry. You get a sanitized message with zero retries, which looks “safe” but fails the flaky-API requirement.

Q12 PII policy for NovaBank account numbers
FlagSetProtects against
apply_to_inputTrueCustomer pastes NB-12345678 in chat. Mask it before the model (and logs/traces of the prompt).
apply_to_outputTrueModel echoes the number back in the assistant reply or in streamed tokens.
apply_to_tool_resultsTruecheck_balance returns the account id in the ToolMessage; that would otherwise be re-fed to the model on the next loop.

All three true for account numbers in a bank assistant. strategy="mask" (this assignment) keeps a little structure for the model; block is for things that must never enter the harness at all (raw card PAN, if you ever collect it).

Q13 LLMToolEmulator’s model discrepancy

The parameter is documented as optional (model=None). The implicit default is not “use the agent’s model” — it is a hardcoded Anthropic id (anthropic:claude-sonnet-4-5-…). If langchain-anthropic is not installed, construction raises ImportError even though your agent is OpenAI. Docs and default disagree; relying on the default is deprecated.

Habit: any middleware that takes its own model= (LLMToolEmulator, LLMToolSelectorMiddleware, SummarizationMiddleware) should get an explicit model. Silent defaults pick a provider you didn’t intend, a bill you didn’t expect, or a missing extra.

Q14 LLM tool selector vs provider tool search
How it filtersConstraint
LLMToolSelectorMiddlewareA (usually cheaper) LLM pre-picks up to max_tools schemas; those are what the main model sees. always_include is never dropped.Works with any tool-calling model. Extra model call each turn.
ProviderToolSearchMiddlewareDoes not pre-filter in Python. Marks tools defer_loading and lets the provider search schemas server-side.Only providers with native tool search (Anthropic Claude 4+ / selected OpenAI). Others raise ValueError.

NovaBank on openai:gpt-5-mini without provider tool-search → use the LLM selector. Don’t pick provider search “because 15 tools.”

Q15 Production stack (open-ended — one coherent answer)

Order below follows outer → inner for wraps, plus node-style hooks that need a checkpointer.

  1. PIIMiddleware("novabank_account", detector=r"NB-\\d{8}", strategy="mask", apply_to_input/output/tool_results=True) — compliance on the wire.
  2. SummarizationMiddleware(model="openai:gpt-5-mini", …) — long threads.
  3. ModelCallLimitMiddleware(run_limit=12, thread_limit=40) — spend cap.
  4. ToolCallLimitMiddleware(tool_name="transfer_funds", run_limit=1, thread_limit=3) — velocity on the dangerous tool.
  5. HumanInTheLoopMiddleware(interrupt_on={"transfer_funds": True}) — human gate. Needs checkpointer.
  6. ToolErrorMiddleware(on_error=safe_handler) then ToolRetryMiddleware(retry_on=(ConnectionError,), on_failure="error") — outer sanitize, inner retry.
  7. ModelRetryMiddleware and/or ModelFallbackMiddleware around the model call for provider blips.
  8. LLMToolSelectorMiddleware(max_tools=4, always_include=["check_balance"], model="openai:gpt-5-mini") if the catalog grows.

Plus: checkpointer + stable thread_id, context_schema for tier. No custom wraps in this assignment.

Part B · Coding

Working solutions

One complete solution per exercise. Environment cell (once): load_dotenv(), assert OPENAI_API_KEY, init_chat_model("openai:gpt-5-mini").

B1.1 — Agent with real memory

Proof is two separate .invoke() calls on the same thread_id with a checkpointer. Without InMemorySaver, call 2 cannot recall the name.

from langchain_core.tools import tool
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

@tool
def check_balance(account_id: str) -> str:
    """Return the fake available balance for an account."""
    balances = {"NB-10000001": 84250.00, "NB-10000002": 1200.50}
    bal = balances.get(account_id, 0.0)
    return f"Available balance for {account_id}: ₹{bal:,.2f}"

@tool
def get_interest_rate() -> str:
    """Return NovaBank's current savings interest rate."""
    return "Current savings rate: 6.5% p.a."

memory_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[check_balance, get_interest_rate],
    checkpointer=InMemorySaver(),
    system_prompt="You are NovaBank's personal assistant.",
    name="novabank-memory",
)
config = {"configurable": {"thread_id": "nb-meera-01"}}

memory_agent.invoke({"messages": [("user", "Hi, my name is Meera.")]}, config=config)
result = memory_agent.invoke(
    {"messages": [("user", "What's my name? Also check NB-10000001.")]},
    config=config,
)
print(result["messages"][-1].content)

B1.2 — Per-run context

runtime.context is injected because of the ToolRuntime annotation — it never appears in the tool schema the model sees. Pass a fresh dataclass on every invoke.

from dataclasses import dataclass
from langchain.tools import ToolRuntime, tool as tool_rt

@dataclass
class BankContext:
    account_tier: str  # "standard" | "premium"

@tool_rt
def greet_customer(runtime: ToolRuntime) -> str:
    """Return a greeting that depends on the caller's account tier."""
    if runtime.context.account_tier == "premium":
        return "Welcome back, NovaBank Premium. A relationship manager is on standby."
    return "Welcome to NovaBank. How can I help today?"

ctx_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[greet_customer],
    context_schema=BankContext,
    system_prompt="Always call greet_customer before answering.",
)

r_std = ctx_agent.invoke({"messages": [("user", "Hello")]}, context=BankContext(account_tier="standard"))
r_prem = ctx_agent.invoke({"messages": [("user", "Hello")]}, context=BankContext(account_tier="premium"))
print("standard:", r_std["messages"][-1].content)
print("premium: ", r_prem["messages"][-1].content)

B2.1 — HITL with an edit resume

Interrupt, then resume on the same thread with Command. Confirm the ToolMessage used ₹5,000, not ₹50,000. Simulator on the Practice tab walks the four decisions without an API key.

from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.types import Command

@tool
def transfer_funds(to_account: str, amount: float) -> str:
    """Transfer funds to another NovaBank account. Consequential — requires HITL."""
    return f"Transferred ₹{amount:,.2f} to {to_account}."

hitl_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[transfer_funds],
    middleware=[HumanInTheLoopMiddleware(
        interrupt_on={"transfer_funds": {
            "allowed_decisions": ["approve", "edit", "reject", "respond"]
        }}
    )],
    checkpointer=InMemorySaver(),
)
cfg = {"configurable": {"thread_id": "nb-hitl-edit"}}

paused = hitl_agent.invoke(
    {"messages": [("user", "Transfer 50000 to NB-20000002")]},
    config=cfg,
)
print("interrupt payload:", paused)

final = hitl_agent.invoke(
    Command(resume={"decisions": [{
        "type": "edit",
        "edited_action": {
            "name": "transfer_funds",
            "args": {"to_account": "NB-20000002", "amount": 5000.0},
        },
    }]}),
    config=cfg,
)
for m in final["messages"]:
    if getattr(m, "type", None) == "tool":
        print("tool result:", m.content)
If your installed LangChain returns GraphOutput, read paused.interrupts and pass version="v2". Class notebooks often used agent.get_state(config) to inspect the pending tool call. Same resume payload either way.

B2.2 — Custom PII detector

from langchain.agents.middleware import PIIMiddleware

pii_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[],
    middleware=[PIIMiddleware(
        "novabank_account",
        detector=r"NB-\d{8}",
        strategy="mask",
        apply_to_input=True,
    )],
)
# "Please look up NB-12345678" is masked before the model sees it.
result = pii_agent.invoke({"messages": [("user", "Please look up NB-12345678")]})
print(result["messages"][-1].content)

A callable detector is also valid if you need span offsets; a regex string is enough for this pattern. Use the Practice tab to see mask vs redact vs block on the same input.

B2.3 — Retry a flaky balance API

Print inside the tool so you can see attempt 1 fail and attempt 2 succeed. Run the cell several times.

import random
from langchain.agents.middleware import ToolRetryMiddleware

@tool
def check_live_balance(account_id: str) -> str:
    """Hit a flaky core-banking API (~50% ConnectionError)."""
    print(f"attempt for {account_id}")
    if random.random() < 0.5:
        raise ConnectionError("core-banking timeout")
    return f"{account_id} balance: ₹12,400.00"

retry_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[check_live_balance],
    middleware=[ToolRetryMiddleware(
        max_retries=4,
        retry_on=(ConnectionError,),
        initial_delay=0.2,
        backoff_factor=2.0,
        jitter=False,
        on_failure="continue",
    )],
)
print(retry_agent.invoke({"messages": [("user", "Live balance for NB-10000001")]})["messages"][-1].content)

B3.1 — Three limit/context middleware together

Each solves a different failure mode at the same time: history growth, runaway transfers, runaway model spend.

from langchain.agents.middleware import (
    SummarizationMiddleware,
    ToolCallLimitMiddleware,
    ModelCallLimitMiddleware,
)

limited = create_agent(
    model="openai:gpt-5-mini",
    tools=[check_balance, transfer_funds, get_interest_rate],
    checkpointer=InMemorySaver(),
    middleware=[
        SummarizationMiddleware(model="openai:gpt-5-mini"),
        ToolCallLimitMiddleware(tool_name="transfer_funds", run_limit=1, thread_limit=3),
        ModelCallLimitMiddleware(run_limit=8, thread_limit=40),
    ],
    name="novabank-limited",
)
cfg = {"configurable": {"thread_id": "nb-limits"}}
print(limited.invoke({"messages": [("user", "What is the savings rate?")]}, config=cfg)["messages"][-1].content)
If your version wants trigger=("tokens", 4000) / keep=("messages", 20) on SummarizationMiddleware, use that — constructors moved slightly across v1 releases. The idea is unchanged: compress history, cap the dangerous tool tightly, cap model calls more loosely.

B3.2 — Tool selection at scale

Assignment forbids custom wraps, so we do not ship the class show_tools debug hook as the official answer. Prove the subset from which tools were actually called (and LangSmith if you have it). always_include=["check_balance"] must survive every filter.

from langchain.agents.middleware import LLMToolSelectorMiddleware

@tool
def list_branches(city: str) -> str:
    """Locate NovaBank branches in a city."""
    return f"Branches in {city}: Bandra West, Andheri East."

@tool
def loan_info(loan_type: str) -> str:
    """Summarize a retail loan product."""
    return f"{loan_type} loan: 9.25% p.a. floating, subject to KYC."

@tool
def open_support_ticket(topic: str) -> str:
    """Create a support ticket."""
    return f"Ticket opened for {topic}."

selector_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[
        check_balance, get_interest_rate, transfer_funds,
        list_branches, loan_info, open_support_ticket,
    ],
    middleware=[LLMToolSelectorMiddleware(
        model="openai:gpt-5-mini",   # explicit — don't inherit a surprise default
        max_tools=3,
        always_include=["check_balance"],
    )],
)
result = selector_agent.invoke({"messages": [("user", "What's my balance on NB-10000001?")]})
print([getattr(m, "name", m.type) for m in result["messages"]])

B3.3 — ToolError outer, ToolRetry inner

Bad input always raises ValueError. Retry it (to show retries are wasted on non-transient errors), then re-raise into ToolError so the model never sees the raw Python text.

from langchain.agents.middleware import ToolErrorMiddleware, ToolRetryMiddleware

@tool
def close_account(account_id: str, confirm: str) -> str:
    """Close an account. Raises ValueError on a bad confirmation phrase."""
    if confirm != "CLOSE":
        raise ValueError(f"Refusing close: got {confirm!r} — internals must not leak")
    return f"Account {account_id} closed."

def safe_on_error(exc: Exception, request) -> str:
    return "That request could not be completed. Re-check the confirmation phrase."

safe_agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[close_account],
    middleware=[
        ToolErrorMiddleware(on_error=safe_on_error),   # OUTER
        ToolRetryMiddleware(                           # INNER
            retry_on=(ValueError,),
            max_retries=2,
            on_failure="error",
            initial_delay=0.05,
            jitter=False,
        ),
    ],
)
result = safe_agent.invoke({"messages": [("user", "Close NB-10000001, confirm=yes")]})
text = " ".join(str(getattr(m, "content", "")) for m in result["messages"])
assert "Refusing close" not in text
assert "internals must not leak" not in text
print(result["messages"][-1].content)
Part C · Capstone

One coherent NovaBank agent

Design (markdown cell, before code)

Six required pieces, all built-in:

  1. response_format=TransferRequest with amount constrained gt=0, le=100000.
  2. Three tools: check_balance, transfer_funds, get_interest_rate.
  3. HumanInTheLoopMiddleware on transfer_funds.
  4. PIIMiddleware masking NB-######## on input, output, and tool results.
  5. ToolRetryMiddleware on the balance tool path (transient ConnectionError).
  6. ToolCallLimitMiddleware tight on transfers + checkpointer / thread_id + context_schema for tier.

HITL already forces a checkpointer, so short-term memory is not an extra object — it is the same InMemorySaver.

from dataclasses import dataclass
from pydantic import BaseModel, Field
from langchain.agents.middleware import (
    HumanInTheLoopMiddleware, PIIMiddleware,
    ToolRetryMiddleware, ToolCallLimitMiddleware,
)

class TransferRequest(BaseModel):
    customer_name: str
    to_account: str
    amount: float = Field(gt=0, le=100000)

@dataclass
class BankContext:
    account_tier: str

capstone = create_agent(
    model="openai:gpt-5-mini",
    tools=[check_balance, transfer_funds, get_interest_rate],
    system_prompt="You are NovaBank's assistant. Mask nothing you weren't given; use tools.",
    context_schema=BankContext,
    response_format=TransferRequest,
    checkpointer=InMemorySaver(),
    name="novabank-capstone",
    middleware=[
        PIIMiddleware(
            "novabank_account", detector=r"NB-\d{8}", strategy="mask",
            apply_to_input=True, apply_to_output=True, apply_to_tool_results=True,
        ),
        ToolCallLimitMiddleware(tool_name="transfer_funds", run_limit=1, thread_limit=3),
        HumanInTheLoopMiddleware(interrupt_on={"transfer_funds": True}),
        ToolRetryMiddleware(retry_on=(ConnectionError,), max_retries=3, on_failure="continue"),
    ],
)
cfg = {"configurable": {"thread_id": "nb-capstone"}}
ctx = BankContext(account_tier="premium")

# Call 1 — short-term memory + structured extract (no transfer yet)
r1 = capstone.invoke(
    {"messages": [("user", "I'm Meera. What's the savings rate?")]},
    config=cfg, context=ctx,
)
print("call 1:", r1["messages"][-1].content)

# Call 2 — same thread should still know Meera; transfer will HITL-pause
r2 = capstone.invoke(
    {"messages": [("user", "Send 1500 to NB-20000002 from my account.")]},
    config=cfg, context=ctx,
)
print("call 2 (expect interrupt):", r2)

Reflection (3–5 sentences)

The next piece I would add, once custom middleware is allowed, is a wrap_model_call velocity gate: hide transfer_funds entirely when this thread has already moved more than ₹25,000 in the last hour, reading a counter from request.state (updated by a Command-returning tool). HITL still reviews each surviving call, but the model should not even see the schema after a burst of transfers — that is a harness guarantee, not a prompt. InMemorySaver / in-memory retry state would also be swapped for Postgres before this left a notebook. I would not start with a new LLM-as-judge layer; the missing control is structural tool gating plus durable memory.
Practice · no API key

Simulators & quizzes

HITL — four decisions

Pending: transfer_funds(to_account="NB-20000002", amount=50000)

Meera typed “send fifty thousand” but meant ₹5,000. Pick a decision.

Waiting for a reviewer…

PII — NB-########

Custom detector NB-\\d{8}
Click a strategy.

Retry / error onion

Same ValueError from close_account
Choose an order.

Quick checks

Forgetting the checkpointer but passing thread_id means…
Chatty NovaBank thread, tiny tools. Which context middleware?
Provider tool search on openai:gpt-5-mini without native tool-search support?
Using HITL respond to deny a transfer is wrong because…

Capstone stack checklist

Toggle at least six. This is a design aid — Q15 has no single correct list.