← Learning Hub · GreenPlate fundamentals
Interactive answer key for Assignment 2. Domain is a personal banking assistant — prove the concepts, don’t rename Cinebot.
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.
create_agent, thread_id, context_schema, and built-in middleware. No @before_model, no subclassing AgentMiddleware.NB-########, compose retry + error, then stack six prebuilt pieces.Click through once before Q1. This is the same loop middleware wraps.
messages.before_model → nested wrap_model_call → LLM sees tools + history..tool_calls. Nothing has executed yet. HITL can interrupt here (after_model).wrap_tool_call (retry, error, emulator). Result becomes a ToolMessage.structured_response).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.
thread_id alone doesn’t persist a conversationYou 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.
thread_id vs context| Lives | NovaBank example | |
|---|---|---|
| thread_id + checkpointer | This conversation, mutable across turns | Customer said “call me Meera” two messages ago |
| context / context_schema | This invoke only — pass fresh every call | account_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.
stream_mode options| Mode | Best for |
|---|---|
messages | Chat UI token stream (typing effect) from the model node. |
updates | Progress: which node just wrote (model vs tools), including HITL pauses. |
custom | Your 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.
name= costs nothing now but matters laterIt 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.
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.
transfer_funds| Decision | NovaBank situation |
|---|---|
approve | Meera asked to send ₹2,000 to her own joint account. Amount and destination match. Let it run. |
edit | Model parsed “fifty thousand” as 50000 but she meant ₹5,000. Change amount before the tool fires. |
reject | Destination looks like a mule account / she says “wait, cancel.” Tool must not run; feed that back as an error ToolMessage. |
respond | The “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. |
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.
| Mechanism | Retries what | NovaBank scenario |
|---|---|---|
ToolRetryMiddleware | The same tool call (backoff) | Core-banking check_live_balance throws ConnectionError 50% of the time. |
ModelRetryMiddleware | The same model HTTP call | Provider 429 / timeout on gpt-5-mini. Don’t swap models; wait and retry. |
| Agent-loop retry (error ToolMessage / validation) | A new model decision | Transfer 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.
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.
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.
| Flag | Set | Protects against |
|---|---|---|
apply_to_input | True | Customer pastes NB-12345678 in chat. Mask it before the model (and logs/traces of the prompt). |
apply_to_output | True | Model echoes the number back in the assistant reply or in streamed tokens. |
apply_to_tool_results | True | check_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).
LLMToolEmulator’s model discrepancyThe 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.
| How it filters | Constraint | |
|---|---|---|
LLMToolSelectorMiddleware | A (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. |
ProviderToolSearchMiddleware | Does 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.”
Order below follows outer → inner for wraps, plus node-style hooks that need a checkpointer.
PIIMiddleware("novabank_account", detector=r"NB-\\d{8}", strategy="mask", apply_to_input/output/tool_results=True) — compliance on the wire.SummarizationMiddleware(model="openai:gpt-5-mini", …) — long threads.ModelCallLimitMiddleware(run_limit=12, thread_limit=40) — spend cap.ToolCallLimitMiddleware(tool_name="transfer_funds", run_limit=1, thread_limit=3) — velocity on the dangerous tool.HumanInTheLoopMiddleware(interrupt_on={"transfer_funds": True}) — human gate. Needs checkpointer.ToolErrorMiddleware(on_error=safe_handler) then ToolRetryMiddleware(retry_on=(ConnectionError,), on_failure="error") — outer sanitize, inner retry.ModelRetryMiddleware and/or ModelFallbackMiddleware around the model call for provider blips.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.
One complete solution per exercise. Environment cell (once): load_dotenv(), assert OPENAI_API_KEY, init_chat_model("openai:gpt-5-mini").
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)
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)
edit resumeInterrupt, 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)
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.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.
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)
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)
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.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"]])
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)
Six required pieces, all built-in:
response_format=TransferRequest with amount constrained gt=0, le=100000.check_balance, transfer_funds, get_interest_rate.HumanInTheLoopMiddleware on transfer_funds.PIIMiddleware masking NB-######## on input, output, and tool results.ToolRetryMiddleware on the balance tool path (transient ConnectionError).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)
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.
transfer_funds(to_account="NB-20000002", amount=50000)Meera typed “send fifty thousand” but meant ₹5,000. Pick a decision.
NB-########NB-\\d{8}ValueError from close_accountthread_id means…openai:gpt-5-mini without native tool-search support?respond to deny a transfer is wrong because…Toggle at least six. This is a design aid — Q15 has no single correct list.