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.
Domains & 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
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 DecisionStart 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.
TipKeep 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
from pydantic import BaseModel, Fieldfrom 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: boolNotePydantic 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
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 MistakeOverlapping, poorly-described tools causing wrong tool selection. If you had a single
process_ordertool 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
from typing import TypedDict, Optionalfrom 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_countlives 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
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 stateWiring the graph
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()TipRun 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
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 DecisionA 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.
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 duplicationCommon MistakeGenerating 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
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 stategraph.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})SecurityNever let the agent itself set
state["approved"] = Truefor 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
## Step 1: run the graph, it pauses at approval_gate for a 6000 INR refundresult = 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 callresult["approved"] = Truefinal_result = app.invoke(result) # resumes from where it pausedTipLog the full
state_snapshotat 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
import loggingimport 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.
{"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 MistakeNo 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
python run_agent.py --order-id ORD-88213 --reason customer_changed_mindExpected 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
## Call execute_refund_idempotent twice with the same idempotency_key## and confirm the payment provider's dashboard shows exactly one refund4. 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.
TipKeep 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.
Videos & Guides
LangGraph Documentation
Official LangGraph docs covering StateGraph, conditional edges, and human-in-the-loop patterns.
Idempotency Keys for Safe Retries
Reference explanation of idempotency keys from a major payment provider, the pattern used in Milestone 3.