Build a Reliable AI Agent with Guardrails and Human Approval

Build a refund-processing agent in LangGraph with schema validation, retries, hard limits, and human-in-the-loop approval.

Related Concepts & TermsProcess

Domains & Technologies

Domains
LANGGRAPHAGENTSFUNCTION-CALLINGRELIABILITY
Technologies

Blueprint Walkthrough

Architecture Overview & Problem Statement

Why This Project Exists

A PhonePe-style support team gets thousands of refund requests a day. An agent that can look up an order and process a refund automatically sounds efficient, until it processes a refund twice because a network call timed out and it retried blindly, or refunds ten thousand rupees on a hundred rupee order because it misread the amount, or loops forever trying the same failed tool call.

A demo agent that calls one tool successfully is not a production agent. This project builds the difference: a single agent with a small, well-designed toolset, schema-validated outputs, a hard step limit, idempotent retries, and a human approval gate for any action above a risk threshold.

What you are building

  • A single agent with three tools: order lookup, refund calculation, refund execution
  • Structured tool outputs validated against a schema before the agent acts on them
  • A hard step limit so a confused agent cannot loop indefinitely
  • Idempotent retry logic so a retried refund call cannot double-charge or double-refund
  • A human-in-the-loop approval gate for any refund above a configured threshold
  • Full logging of every tool call and decision for debugging

Architecture diagram

◈ DIAGRAM
User Request: "Refund order 88213"
|
v
+----------------+
| Agent Reasons | <-- step counter increments here
+----------------+
|
v
+----------------+ +------------------+
| Tool: Lookup | --> | Schema Validation |
| Order | +------------------+
+----------------+ |
| v
v +------------------+
+----------------+ | Amount > 5000? |
| Tool: Calc | --> +------------------+
| Refund | | |
+----------------+ No Yes
| |
v v
+---------+ +-----------+
| Execute | | Human |
| Refund | | Approval |
+---------+ | Gate |
| +-----------+
v |
+----------------+
| Log & Respond |
+----------------+
Engineering Decision

Start with a single agent, a small toolset, and reliability guardrails from day one. Multi-agent orchestration is tempting to reach for early, but a single agent with three well-scoped tools and proper guardrails handles the vast majority of real support-automation cases. Reach for multi-agent only once you can prove, with evidence, that a single agent genuinely cannot handle the task, that comparison is Project 4.

Prerequisites

This project assumes you have completed the LLM Fundamentals module and are comfortable with function calling concepts. You do not need prior LangGraph experience, this project teaches it as you build.

Tip

Keep the Milestone 5 debugging log open in a separate file as you work through every milestone. Real bugs you hit yourself while building this agent are exactly what Milestone 5 asks you to diagnose later, do not paper over them as you go.

Milestone 1: Design Narrow Tools with Schema Validation

Why Tool Design Is the Real Skill, Not Prompting

An agent is only as reliable as the tools it can call. A tool named handle_order that does five different things depending on hidden internal logic will confuse the agent about when to use it. Three narrow, clearly-named tools, each doing exactly one thing, are far easier for both the agent and a human debugging it to reason about.

Defining the tool schemas

PYTHON
from pydantic import BaseModel, Field
from typing import Literal
class OrderLookupInput(BaseModel):
order_id: str = Field(description="The order ID to look up, e.g. ORD-88213")
class OrderLookupOutput(BaseModel):
order_id: str
customer_id: str
order_value: float
order_status: Literal["delivered", "cancelled", "in_transit", "refunded"]
order_date: str
class RefundCalculationInput(BaseModel):
order_id: str
reason: Literal["damaged", "not_delivered", "wrong_item", "customer_changed_mind"]
class RefundCalculationOutput(BaseModel):
order_id: str
refund_amount: float
refund_percentage: float
requires_approval: bool
Note

Pydantic models here do two jobs at once, they define the exact shape the agent must produce, and they automatically reject a malformed response before it ever reaches the refund execution step. This is what "structured tool outputs" means in practice, not just asking the model nicely for JSON.

Implementing the tools

PYTHON
def lookup_order(order_id: str) -> OrderLookupOutput:
"""
Fetch order details from the orders database.
Returns a validated OrderLookupOutput, raising a clear error
if the order_id does not exist rather than returning None silently.
"""
order = db.orders.find_one({"order_id": order_id})
if not order:
raise ValueError(f"No order found with ID {order_id}")
return OrderLookupOutput(**order)
def calculate_refund(order_id: str, reason: str) -> RefundCalculationOutput:
"""
Calculate refund amount based on order value and reason.
Damaged/not_delivered get full refund, customer_changed_mind gets 80%
to cover processing costs, this mirrors a real Flipkart-style policy.
"""
order = lookup_order(order_id)
refund_rates = {
"damaged": 1.0,
"not_delivered": 1.0,
"wrong_item": 1.0,
"customer_changed_mind": 0.8
}
percentage = refund_rates[reason]
amount = round(order.order_value * percentage, 2)
return RefundCalculationOutput(
order_id=order_id,
refund_amount=amount,
refund_percentage=percentage,
requires_approval=amount > 5000 # threshold covered in Milestone 4
)
Common Mistake

Overlapping, poorly-described tools causing wrong tool selection. If you had a single process_order tool that both looked up orders and issued refunds depending on a hidden flag, the agent has no reliable way to know which behaviour it is triggering from the tool name and description alone. One tool, one clear purpose, one clear name.

Guided practice

Write the third tool, execute_refund, following the same pattern: a Pydantic input model, a Pydantic output model, and a function that validates the order status is delivered before allowing a refund (a cancelled order should never accept a refund call at all, that is a data integrity bug the tool itself should catch).

Milestone 2: Build the Agent Loop in LangGraph

The Reason-Act-Observe Pattern, Not Reinvented Each Time

An agent's core loop is: reason about what to do next, act by calling a tool, observe the result, and repeat until the task is done or a limit is hit. This pattern is often called ReAct in the literature. LangGraph gives you explicit control over this loop as a graph of nodes and edges, rather than an opaque black-box loop you cannot inspect or interrupt.

Why LangGraph over a plain while loop

A hand-rolled while loop calling the LLM repeatedly works for a demo, but it is difficult to insert a hard step limit, a human approval pause, or structured logging cleanly into an ad-hoc loop. LangGraph makes each of those a named node in an explicit graph, which is why this project uses it instead of raw API calls.

Defining the agent state

PYTHON
from typing import TypedDict, Optional
from langgraph.graph import StateGraph, END
class AgentState(TypedDict):
user_request: str
order_id: Optional[str]
order_details: Optional[dict]
refund_calculation: Optional[dict]
step_count: int
requires_approval: bool
approved: Optional[bool]
final_response: Optional[str]
Note

step_count lives directly in the shared state, not as a separate counter outside the graph. This means every node that touches state can see and increment it, which is what makes the hard step limit in Milestone 3 possible to enforce centrally instead of scattered across each tool.

Building the graph nodes

PYTHON
def reason_node(state: AgentState) -> AgentState:
"""
Ask the LLM what to do next given current state.
Increments step_count on every pass through this node,
this is the single place step count changes.
"""
state["step_count"] += 1
# LLM call decides next action based on state["user_request"]
# and what has been gathered so far (order_details, etc.)
return state
def lookup_node(state: AgentState) -> AgentState:
"""Call the order lookup tool and store validated output in state."""
result = lookup_order(state["order_id"])
state["order_details"] = result.model_dump()
return state
def calculate_node(state: AgentState) -> AgentState:
"""Call the refund calculation tool and store the result."""
result = calculate_refund(state["order_id"], reason="customer_changed_mind")
state["refund_calculation"] = result.model_dump()
state["requires_approval"] = result.requires_approval
return state

Wiring the graph

PYTHON
graph = StateGraph(AgentState)
graph.add_node("reason", reason_node)
graph.add_node("lookup", lookup_node)
graph.add_node("calculate", calculate_node)
graph.set_entry_point("reason")
graph.add_edge("reason", "lookup")
graph.add_edge("lookup", "calculate")
graph.add_edge("calculate", END) # approval gate added in Milestone 4
app = graph.compile()
Tip

Run the graph with a single test order end to end before adding the hard step limit or approval gate. Confirming the happy path works first makes it far easier to tell whether a later bug came from the guardrail logic or from the base agent flow itself.

Troubleshooting scenario

After compiling the graph, app.invoke() throws a KeyError on order_id. Before changing the node code, check whether the initial state dictionary passed into invoke() actually included every key defined in AgentState, a TypedDict does not enforce required keys at runtime the way Pydantic does, missing keys fail only when a node tries to read them.

Milestone 3: Add Hard Step Limits and Idempotent Retries

Why an Agent Needs a Hard Ceiling

An agent reasoning in a loop can get stuck: a tool call fails, the agent tries a slightly different approach, that fails too, and without a hard limit this can continue indefinitely, burning API cost and never resolving. A hard step limit is a blunt but essential safety net, independent of how well the agent reasons.

Enforcing the step limit

PYTHON
MAX_STEPS = 8
def check_step_limit(state: AgentState) -> str:
"""
Conditional edge function, routes to END if the step limit
is exceeded, otherwise continues the normal flow.
This check happens after every reason_node pass.
"""
if state["step_count"] >= MAX_STEPS:
state["final_response"] = (
"I was unable to complete this request within the allowed "
"number of steps. Escalating to a human agent."
)
return "escalate"
return "continue"
graph.add_conditional_edges(
"reason",
check_step_limit,
{"continue": "lookup", "escalate": END}
)
Engineering Decision

A hard step limit that ends in escalation to a human, not a silent failure. An agent that hits its limit and simply stops with no explanation leaves the customer with nothing. Escalating with a clear message is the difference between a guardrail and a dead end.

Why retries need idempotency, not just a retry decorator

A network timeout on a refund call does not tell you whether the refund actually processed before the connection dropped. Retrying blindly risks a double refund. Idempotency means the operation can be safely repeated without changing the outcome beyond the first successful call, this is achieved with a unique idempotency key per logical operation, not per HTTP attempt.

PYTHON
import uuid
def execute_refund_idempotent(order_id: str, amount: float, idempotency_key: str = None):
"""
idempotency_key is generated once per refund REQUEST, not per retry attempt.
The payment provider (Razorpay-style) deduplicates on this key,
so retrying with the same key is always safe.
"""
if idempotency_key is None:
idempotency_key = str(uuid.uuid4())
for attempt in range(3):
try:
response = payment_client.refunds.create(
order_id=order_id,
amount=amount,
idempotency_key=idempotency_key # same key across all attempts
)
return response
except TimeoutError:
if attempt == 2:
raise
continue # safe to retry, same idempotency_key prevents duplication
Common Mistake

Generating a new idempotency key on every retry attempt instead of once per logical request. If each retry gets its own key, the payment provider treats each attempt as a genuinely new refund request, and idempotency provides zero protection, the exact bug it exists to prevent.

Concept check

Before continuing, answer without looking back: if execute_refund_idempotent is called twice with the same idempotency_key because a network response was lost even though the refund succeeded server-side, what happens on the second call? Write your answer, then verify it against how the payment provider's idempotency deduplication actually works in their documentation.

Milestone 4: Add a Human-in-the-Loop Approval Gate

Matching Autonomy to Risk

A hundred-rupee refund on a clearly damaged item does not need a human in the loop, the cost of a wrong automated decision is small. A fifteen-thousand-rupee refund does. Guardrail intensity should match actual risk, not apply uniformly everywhere, over-approving low-risk actions just trains support staff to rubber-stamp everything without reading them.

Building the approval node

PYTHON
def approval_gate_node(state: AgentState) -> AgentState:
"""
Pauses the graph and waits for a human decision when
requires_approval is True. In production this writes to
a queue a support lead reviews, here it is simulated
with an interrupt for local testing.
"""
if not state["requires_approval"]:
state["approved"] = True
return state
# In production: push to an approval queue and pause execution
# until a human responds, rather than blocking synchronously
pending_approval_queue.push({
"order_id": state["order_id"],
"refund_amount": state["refund_calculation"]["refund_amount"],
"state_snapshot": state
})
state["approved"] = None # explicitly unresolved, not yet decided
return state
PYTHON
graph.add_node("approval_gate", approval_gate_node)
graph.add_edge("calculate", "approval_gate")
def route_after_approval(state: AgentState) -> str:
if state["approved"] is True:
return "execute"
elif state["approved"] is False:
return "denied"
else:
return "waiting" # graph pauses here until a human resumes it
graph.add_conditional_edges(
"approval_gate",
route_after_approval,
{"execute": "execute_refund", "denied": END, "waiting": END}
)
Security

Never let the agent itself set state["approved"] = True for a request that required approval. That decision must come from a separate, authenticated human action, an agent that can approve its own high-risk actions has no real guardrail at all, it is theater.

Simulating the approval flow end to end

PYTHON
## Step 1: run the graph, it pauses at approval_gate for a 6000 INR refund
result = app.invoke({
"user_request": "Refund order ORD-88213, customer changed mind",
"order_id": "ORD-88213",
"step_count": 0,
"requires_approval": False,
"approved": None
})
print(result["approved"]) # None - awaiting human decision
## Step 2: a human support lead reviews and approves in a separate call
result["approved"] = True
final_result = app.invoke(result) # resumes from where it paused
Tip

Log the full state_snapshot at the moment of pausing for approval, not just the refund amount. A human reviewer approving a refund with zero context about why the agent calculated that amount is being asked to rubber-stamp a decision they cannot actually evaluate.

Guided practice

Set the approval threshold to a value that makes roughly half of a batch of 10 test orders require approval, then run all 10 through the graph and confirm the low-value ones complete automatically while the high-value ones correctly pause and wait.

Milestone 5: Log Everything and Debug a Looping Agent

Why Undebuggable Failures Are the Real Cost

An agent that fails silently, with no record of what it tried or why, cannot be fixed, only guessed at. Logging every tool call, every reasoning step, and every decision is what turns "the agent is broken somehow" into "the agent called lookup_order with an empty string on step 3, here is exactly why."

Structured logging for every node

PYTHON
import logging
import json
logger = logging.getLogger("refund_agent")
def log_node_execution(node_name: str, state: AgentState, result: dict = None):
"""
Log every node's execution with enough context to reconstruct
the agent's decision path after the fact, without needing to
reproduce the exact same failure live.
"""
logger.info(json.dumps({
"node": node_name,
"step_count": state.get("step_count"),
"order_id": state.get("order_id"),
"result": result,
"timestamp": datetime.utcnow().isoformat()
}))

Wrap each node function from Milestones 2 through 4 with this logging call at entry and exit. This is intentionally simple, structured JSON logs, not a complex tracing framework, that is exactly what the LLMOps module covers in depth later.

Debugging a real looping agent

Here is a deliberately broken scenario to diagnose. An agent is looping between reason and lookup without ever reaching calculate.

JSON
{"node": "reason", "step_count": 1, "order_id": "ORD-99102"}
{"node": "lookup", "step_count": 1, "order_id": "ORD-99102", "result": "error: order not found"}
{"node": "reason", "step_count": 2, "order_id": "ORD-99102"}
{"node": "lookup", "step_count": 2, "order_id": "ORD-99102", "result": "error: order not found"}
{"node": "reason", "step_count": 3, "order_id": "ORD-99102"}

Reading these logs top to bottom: the order ID does not exist, lookup_order raises a ValueError each time, but the reason node has no logic for handling a tool error, it simply retries the same failing call. The step limit will eventually stop this, but the actual fix is giving the reason node the ability to recognize a ValueError from a tool and route to a different response entirely, rather than mindlessly retrying an unrecoverable failure.

Common Mistake

No logging of intermediate reasoning, making failures undebuggable. Without the log lines above, this scenario looks like "the agent is stuck" with no way to know whether the order ID was wrong, the database was down, or the agent's reasoning logic itself was faulty, three completely different fixes.

Troubleshooting scenario

Add error-aware handling to reason_node: if the previous tool call's result contains an error, the agent should route to a clear "order not found, please verify the order ID" response instead of retrying. Implement this, then re-run the broken scenario above and confirm it now resolves in 2 steps instead of hitting the step limit.

Validation & Testing

Final Verification

1. Happy path check

Bash
python run_agent.py --order-id ORD-88213 --reason customer_changed_mind

Expected output: a completed refund for an order under the approval threshold, with a full log trail showing reason -> lookup -> calculate -> approval_gate -> execute -> end, and step_count no higher than 4.

2. Approval gate check

Run the same command against an order valued above your approval threshold. Expected output: execution pauses at approval_gate with approved: None, and the logged state_snapshot contains enough detail (order ID, amount, reason) for a human reviewer to make an informed decision without re-running anything.

3. Idempotent retry check

PYTHON
## Call execute_refund_idempotent twice with the same idempotency_key
## and confirm the payment provider's dashboard shows exactly one refund

4. Hard step limit check

Deliberately point the agent at a nonexistent order ID and confirm it escalates to a human with a clear message at or before step 8, rather than looping indefinitely or crashing.

5. Debug log check

Confirm every node in the graph produces a structured log entry, and that reading the logs alone, without re-running the agent, is enough to explain any test case's outcome.

Quick reference

Guardrail Purpose Failure symptom if missing
Schema validation Reject malformed tool outputs Agent acts on garbage data silently
Hard step limit Stop infinite loops Runaway API cost, never resolves
Idempotency key Prevent duplicate actions on retry Double refunds on network failure
Approval gate Match autonomy to risk High-risk actions execute unsupervised
Structured logging Make failures debuggable "It's stuck somehow" with no fix path

Common mistakes across the full project

Overlapping, poorly-described tools cause the agent to pick the wrong one, and the fix is one tool per clear purpose with an unambiguous name and description. No max-step limit lets a confused agent loop indefinitely, and the fix is a hard ceiling that escalates to a human rather than failing silently. Executing high-risk actions with no human approval turns one bad automated decision into a real financial or customer-trust incident, and the fix is a risk-based approval gate, not blanket automation. Reaching for multi-agent orchestration before proving a single agent cannot handle the task adds coordination complexity without proven benefit, and the fix is treating Project 4's comparison as a prerequisite, not a formality. No logging of intermediate reasoning makes every failure a mystery, and the fix is structured logging on every node from the very first version of the agent, not added later after an incident forces the issue.

Tip

Keep this agent's code, Project 3 wraps it in a FastAPI service with authentication and deployment, and Project 4 compares it directly against a multi-agent version on the same task.