## 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 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.
## 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).
## 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.
## 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.
## 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.
## 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`. ```text {"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.
Why This Project Exists A PhonePe-style support team gets thousands of refund requests a day. An agent that can look up ...
Why Tool Design Is the Real Skill, Not Prompting An agent is only as reliable as the tools it can call. A tool named han...
The Reason-Act-Observe Pattern, Not Reinvented Each Time An agent's core loop is: reason about what to do next, act by c...
Why an Agent Needs a Hard Ceiling An agent reasoning in a loop can get stuck: a tool call fails, the agent tries a sligh...
Matching Autonomy to Risk A hundred-rupee refund on a clearly damaged item does not need a human in the loop, the cost o...
Why Undebuggable Failures Are the Real Cost An agent that fails silently, with no record of what it tried or why, cannot...
Final Verification 1. Happy path check Expected output: a completed refund for an order under the approval threshold, wi...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.