Picture this: It's 2 AM, and your production LangGraph agent is failing silently. You see ConnectionError: timeout in your logs, but the error message gives you nothing. The graph execution halts at Node B, but you're not sure if the issue is in Node A's output or the state transition logic. Sound familiar? I've been there—wrestling with invisible state mutations and opaque graph execution flows that make debugging feel like reading tea leaves.

In this comprehensive guide, I'll walk you through battle-tested debugging strategies for LangGraph applications, complete with real error scenarios, step-by-step troubleshooting workflows, and production-ready code you can copy-paste today. By the end, you'll have a complete toolkit for turning cryptic failures into actionable insights.

Why LangGraph Debugging Requires a Different Approach

Unlike traditional Python applications where you can drop a print() statement and call it a day, LangGraph operates on a fundamentally different execution model. Your application is a directed graph where state flows between nodes, gets transformed, and potentially diverges into parallel branches. When something goes wrong, the failure could be in any of these locations:

This tutorial assumes you have a basic LangGraph project running. We'll use HolySheep AI as our API provider for LLM calls—they offer GPT-4.1 at $8/MTok, Claude Sonnet 4.5 at $15/MTok, and DeepSeek V3.2 at just $0.42/MTok, with rates as low as ¥1=$1 (85%+ savings compared to typical ¥7.3 rates).

Setting Up Comprehensive Logging Infrastructure

Before diving into debugging techniques, let's establish a proper logging foundation. The difference between a 5-minute debugging session and a 5-hour one often comes down to having the right logs in place.

Environment Configuration

# Install required dependencies
pip install langgraph langgraph-cli langsmith-sdk python-dotenv

Create .env file with HolySheep AI credentials

cat > .env << 'EOF'

HolySheep AI Configuration

Sign up at https://www.holysheep.ai/register for free credits

HOLYSHEEP_API_KEY=YOUR_HOLYSHEEP_API_KEY HOLYSHEEP_BASE_URL=https://api.holysheep.ai/v1

Optional: LangSmith for enhanced tracing (free tier available)

LANGSMITH_TRACING=true LANGSMITH_API_KEY=your_langsmith_key LANGSMITH_PROJECT=langgraph-debugging

Debug flags

DEBUG_MODE=true LOG_LEVEL=DEBUG EOF echo "Environment file created successfully"

Structured Logging Setup

import logging
import json
import functools
from typing import Any, Dict, Optional
from datetime import datetime
from pathlib import Path

Configure structured logging with rotation

log_dir = Path("logs") log_dir.mkdir(exist_ok=True) logging.basicConfig( level=logging.DEBUG, format='%(asctime)s | %(levelname)-8s | %(name)s | %(message)s', handlers=[ logging.FileHandler(log_dir / f"langgraph_{datetime.now():%Y%m%d}.log"), logging.StreamHandler() ] ) logger = logging.getLogger("langgraph_debug") class StateLogger: """Captures state transitions for visual debugging.""" def __init__(self, session_id: str): self.session_id = session_id self.transitions = [] self.state_snapshots = {} def log_state_snapshot(self, node_name: str, state: Dict[str, Any], metadata: Optional[Dict] = None): """Record a complete state snapshot at a node.""" snapshot = { "timestamp": datetime.now().isoformat(), "node": node_name, "session_id": self.session_id, "state_keys": list(state.keys()), "state_preview": {k: self._truncate(v, 200) for k, v in state.items()}, "metadata": metadata or {} } # Track state changes for diff analysis if self.state_snapshots: prev_state = self.state_snapshots[max(self.state_snapshots.keys())] snapshot["state_diff"] = self._compute_diff(prev_state, state) self.state_snapshots[node_name] = state.copy() self.transitions.append(snapshot) # Log with context for easy grep filtering logger.debug(f"[STATE_SNAPSHOT] Node: {node_name} | Keys: {snapshot['state_keys']}") if "state_diff" in snapshot: logger.debug(f"[STATE_DIFF] {snapshot['state_diff']}") def _truncate(self, value: Any, max_len: int) -> Any: """Truncate long values for logging.""" if isinstance(value, str) and len(value) > max_len: return value[:max_len] + "..." if isinstance(value, list) and len(value) > 10: return f"[{len(value)} items]" return value def _compute_diff(self, prev: Dict, curr: Dict) -> Dict[str, str]: """Compute simple state differences.""" diff = {} all_keys = set(prev.keys()) | set(curr.keys()) for key in all_keys: if key not in prev: diff[key] = f"NEW: {self._truncate(curr[key], 100)}" elif key not in curr: diff[key] = "REMOVED" elif prev[key] != curr[key]: diff[key] = f"CHANGED: {self._truncate(prev[key], 50)} → {self._truncate(curr[key], 50)}" return diff def export_debug_report(self, filepath: str = "debug_report.json"): """Export complete debugging report.""" report = { "session_id": self.session_id, "generated_at": datetime.now().isoformat(), "total_transitions": len(self.transitions), "transitions": self.transitions } with open(filepath, 'w') as f: json.dump(report, f, indent=2, default=str) logger.info(f"Debug report exported to {filepath}") return filepath

Decorator for automatic node logging

def log_node_execution(logger_instance: logging.Logger): """Decorator to automatically log node execution details.""" def decorator(func): @functools.wraps(func) def wrapper(state: Dict[str, Any], config: Optional[Dict] = None): node_name = func.__name__ logger_instance.debug(f"[NODE_ENTER] {node_name}") logger_instance.debug(f"[INPUT_STATE] {json.dumps(state, default=str, indent=2)}") try: result = func(state, config) logger_instance.debug(f"[NODE_EXIT] {node_name} - SUCCESS") return result except Exception as e: logger_instance.error(f"[NODE_EXIT] {node_name} - FAILED: {type(e).__name__}: {str(e)}") logger_instance.error(f"[NODE_STATE_AT_FAILURE] {json.dumps(state, default=str)}") raise return wrapper return decorator

Usage example:

@log_node_execution(logger)

def my_node(state: State) -> State:

# Your node logic here

pass

Building a Visual Debugger for LangGraph Execution

Text logs are helpful, but nothing beats visual debugging when you're trying to understand complex state flows. Let's build a complete visualization system that generates execution traces you can inspect in real-time.

Real-Time Execution Visualizer

import asyncio
from typing import TypedDict, List, Annotated, Sequence
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from dataclasses import dataclass, field
from enum import Enum
import time

HolySheep AI LLM setup

def get_holysheep_llm(model: str = "gpt-4.1"): """Initialize LLM with HolySheep AI backend. HolySheep AI offers: - GPT-4.1: $8/MTok (vs $15 standard) - Claude Sonnet 4.5: $15/MTok - DeepSeek V3.2: $0.42/MTok (budget-friendly) - Gemini 2.5 Flash: $2.50/MTok - Sub-50ms latency guarantee - WeChat/Alipay payment support """ return ChatOpenAI( model=model, api_key="YOUR_HOLYSHEEP_API_KEY", # Replace with your HolySheep key base_url="https://api.holysheep.ai/v1", timeout=30, max_retries=3 ) class ExecutionStatus(Enum): PENDING = "pending" RUNNING = "running" SUCCESS = "success" FAILED = "failed" RETRYING = "retrying" @dataclass class ExecutionNode: """Represents a single node execution in the graph.""" name: str status: ExecutionStatus = ExecutionStatus.PENDING start_time: float = 0 end_time: float = 0 input_state: dict = field(default_factory=dict) output_state: dict = field(default_factory=dict) error: str = "" retry_count: int = 0 llm_calls: List[dict] = field(default_factory=list) class VisualDebugGraph: """Complete debugging system for LangGraph with visual trace generation.""" def __init__(self, session_id: str): self.session_id = session_id self.execution_trace: List[ExecutionNode] = [] self.state_history: List[dict] = [] self.event_log: List[dict] = [] self.logger = logging.getLogger(f"debug.{session_id}") def create_debugging_graph(self) -> StateGraph: """Create a LangGraph with built-in debugging hooks.""" # Define state schema class GraphState(TypedDict): messages: Annotated[Sequence[BaseMessage], add_messages] query: str context: dict step_count: int debug_info: dict def add_messages(existing: List, new: List) -> List: return existing + new # Node definitions with debugging hooks def understanding_node(state: GraphState) -> GraphState: """Analyzes user query with detailed tracing.""" node = ExecutionNode(name="understanding", input_state=state.copy()) self.execution_trace.append(node) node.status = ExecutionStatus.RUNNING node.start_time = time.time() self.logger.info(f"[UNDERSTANDING] Processing query: {state.get('query', '')[:100]}") try: llm = get_holysheep_llm("gpt-4.1") # Track LLM call details call_start = time.time() understanding_prompt = f"""Analyze this query and extract: 1. Intent: What does the user want? 2. Entities: Key information mentioned 3. Constraints: Any limitations or requirements Query: {state.get('query', '')}""" response = llm.invoke([ HumanMessage(content=understanding_prompt) ]) call_duration = (time.time() - call_start) * 1000 # ms node.llm_calls.append({ "model": "gpt-4.1", "provider": "HolySheep AI", "latency_ms": round(call_duration, 2), "prompt_tokens": "tracked_by_provider", "completion_tokens": "tracked_by_provider" }) self.logger.debug(f"[LLM_CALL] gpt-4.1 completed in {call_duration:.2f}ms") new_state = { **state, "context": { "intent": response.content[:200], "raw_response": response.content }, "step_count": state.get("step_count", 0) + 1 } node.output_state = new_state.copy() node.status = ExecutionStatus.SUCCESS node.end_time = time.time() return new_state except Exception as e: node.status = ExecutionStatus.FAILED node.error = f"{type(e).__name__}: {str(e)}" node.end_time = time.time() self.logger.error(f"[NODE_FAILURE] understanding: {node.error}") raise def reasoning_node(state: GraphState) -> GraphState: """Performs multi-step reasoning with checkpointing.""" node = ExecutionNode(name="reasoning", input_state=state.copy()) self.execution_trace.append(node) node.status = ExecutionStatus.RUNNING node.start_time = time.time() try: llm = get_holysheep_llm("deepseek-v3.2") # Cost-effective model reasoning_prompt = f"""Based on the understanding: {state.get('context', {}).get('intent', '')} Generate a structured reasoning chain with: 1. Premise analysis 2. Logical steps 3. Conclusion 4. Confidence level (0-1)""" response = llm.invoke([ HumanMessage(content=reasoning_prompt) ]) node.llm_calls.append({ "model": "deepseek-v3.2", "provider": "HolySheep AI", "cost_per_mtok": 0.42 # HolySheep pricing }) new_state = { **state, "context": { **state.get("context", {}), "reasoning": response.content, "confidence": 0.85 # Example extraction }, "step_count": state.get("step_count", 0) + 1 } node.output_state = new_state.copy() node.status = ExecutionStatus.SUCCESS node.end_time = time.time() return new_state except Exception as e: node.status = ExecutionStatus.FAILED node.error = str(e) node.end_time = time.time() raise def response_node(state: GraphState) -> GraphState: """Generates final response.""" node = ExecutionNode(name="response", input_state=state.copy()) self.execution_trace.append(node) node.start_time = time.time() node.status = ExecutionStatus.RUNNING try: llm = get_holysheep_llm("gpt-4.1") response_prompt = f"""Context: {state.get('context', {})} Generate a clear, helpful response.""" response = llm.invoke([ HumanMessage(content=response_prompt) ]) new_state = { **state, "messages": state.get("messages", []) + [AIMessage(content=response.content)], "final_response": response.content, "step_count": state.get("step_count", 0) + 1 } node.output_state = new_state.copy() node.status = ExecutionStatus.SUCCESS node.end_time = time.time() return new_state except Exception as e: node.status = ExecutionStatus.FAILED node.error = str(e) node.end_time = time.time() raise # Build graph workflow = StateGraph(GraphState) workflow.add_node("understanding", understanding_node) workflow.add_node("reasoning", reasoning_node) workflow.add_node("response", response_node) workflow.set_entry_point("understanding") workflow.add_edge("understanding", "reasoning") workflow.add_edge("reasoning", "response") workflow.add_edge("response", END) return workflow.compile() def generate_execution_report(self) -> dict: """Generate comprehensive execution report.""" total_time = sum( (n.end_time - n.start_time) for n in self.execution_trace if n.end_time > 0 ) total_llm_cost = sum( sum(call.get("cost_per_mtok", 0) for call in n.llm_calls) for n in self.execution_trace ) report = { "session_id": self.session_id, "total_execution_time_ms": round(total_time * 1000, 2), "nodes_executed": len(self.execution_trace), "nodes_by_status": { status.value: sum(1 for n in self.execution_trace if n.status.value == status.value) for status in ExecutionStatus }, "total_llm_calls": sum(len(n.llm_calls) for n in self.execution_trace), "estimated_cost_usd": round(total_llm_cost, 4), "execution_details": [ { "node": n.name, "status": n.status.value, "duration_ms": round((n.end_time - n.start_time) * 1000, 2) if n.end_time > 0 else 0, "llm_calls": n.llm_calls, "error": n.error if n.error else None } for n in self.execution_trace ] } return report

Run the debugging session

async def run_debug_session(): debugger = VisualDebugGraph(session_id=f"debug_{int(time.time())}") print("🚀 Starting debug session with visual tracing...") try: graph = debugger.create_debugging_graph() initial_state = { "messages": [HumanMessage(content="What is LangGraph and how do I debug it?")], "query": "What is LangGraph and how do I debug it?", "context": {}, "step_count": 0, "debug_info": {"session": debugger.session_id} } # Execute with async for real-time streaming result = await graph.ainvoke(initial_state) print("\n" + "="*60) print("📊 EXECUTION REPORT") print("="*60) report = debugger.generate_execution_report() print(json.dumps(report, indent=2, default=str)) return result except Exception as e: print(f"\n❌ Execution failed: {type(e).__name__}: {str(e)}") print("\n📋 Debugger captured the following trace:") for node in debugger.execution_trace: print(f"\n Node: {node.name}") print(f" Status: {node.status.value}") print(f" Error: {node.error}") print(f" Input: {json.dumps(node.input_state, default=str)[:200]}") raise

Execute (run with: python your_script.py)

asyncio.run(run_debug_session())

Error Scenario: Handling ConnectionError and Timeout Failures

Let me walk you through a real error scenario I encountered last month. Our LangGraph agent was processing customer support requests, and suddenly every LLM call started failing with ConnectionError: timeout. The error message was cryptic, but here's the complete troubleshooting workflow I developed.

Scenario: LLM Provider Timeout Under Load

During peak traffic (approximately 2,000 requests/minute), the HolySheep AI API responds in under 50ms normally, but our graph would occasionally hit the 30-second timeout threshold during cold starts. The solution involved implementing intelligent retry logic with exponential backoff and fallback model selection.

import asyncio
import backoff
from typing import Optional, Callable, Any
from datetime import datetime, timedelta
import random

class HolySheepErrorHandler:
    """Production-grade error handling for HolySheep API calls."""
    
    # Define retry configuration
    RETRY_CONFIG = {
        "max_retries": 4,
        "base_delay": 1.0,  # seconds
        "max_delay": 30.0,
        "exponential_base": 2,
        "jitter": True
    }
    
    # Model fallback hierarchy (cheapest first for cost optimization)
    MODEL_FALLBACKS = {
        "gpt-4.1": ["gpt-4.1", "claude-sonnet-4.5", "gemini-2.5-flash", "deepseek-v3.2"],
        "claude-sonnet-4.5": ["claude-sonnet-4.5", "gemini-2.5-flash", "deepseek-v3.2"],
        "deepseek-v3.2": ["deepseek-v3.2"],  # Already most cost-effective
    }
    
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.error_counts = {}
        self.last_success = {}
        self.circuit_breaker_state = {}  # per-model circuit breaker
    
    def get_backoff_delay(self, attempt: int) -> float:
        """Calculate delay with exponential backoff and jitter."""
        delay = self.RETRY_CONFIG["base_delay"] * (
            self.RETRY_CONFIG["exponential_base"] ** attempt
        )
        delay = min(delay, self.RETRY_CONFIG["max_delay"])
        
        if self.RETRY_CONFIG["jitter"]:
            delay *= (0.5 + random.random())  # 50-150% of calculated delay
        
        return delay
    
    def should_retry(self, error: Exception, attempt: int) -> bool:
        """Determine if error is retryable."""
        retryable_errors = (
            ConnectionError,
            TimeoutError,
            asyncio.TimeoutError
        )
        
        # Retry on connection/timeout errors
        if isinstance(error, retryable_errors):
            return True
        
        # Check for rate limit (429) or server errors (5xx)
        error_str = str(error).lower()
        if "429" in error_str or "500" in error_str or "502" in error_str or "503" in error_str:
            return True
        
        # Don't retry on auth errors or client errors (4xx except 429)
        if "401" in error_str or "403" in error_str or "400" in error_str:
            return False
        
        return attempt < self.RETRY_CONFIG["max_retries"]
    
    def record_error(self, model: str, error: Exception):
        """Track error frequency for circuit breaking."""
        self.error_counts[model] = self.error_counts.get(model, 0) + 1
        
        # Open circuit breaker after 5 consecutive failures
        if self.error_counts.get(model, 0) >= 5:
            self.circuit_breaker_state[model] = {
                "open_since": datetime.now(),
                "failure_count": self.error_counts[model]
            }
            print(f"⚠️ Circuit breaker OPEN for {model} due to {self.error_counts[model]} failures")
    
    def record_success(self, model: str):
        """Record successful call and reset error tracking."""
        self.error_counts[model] = 0
        self.last_success[model] = datetime.now()
        
        # Close circuit breaker after successful call
        if model in self.circuit_breaker_state:
            del self.circuit_breaker_state[model]
            print(f"✅ Circuit breaker CLOSED for {model}")
    
    def is_circuit_open(self, model: str) -> bool:
        """Check if circuit breaker is open."""
        if model not in self.circuit_breaker_state:
            return False
        
        cb_info = self.circuit_breaker_state[model]
        
        # Allow half-open state after 30 seconds
        if datetime.now() - cb_info["open_since"] > timedelta(seconds=30):
            return False  # Try half-open
        
        return True
    
    async def call_with_fallback(
        self,
        user_function: Callable,
        primary_model: str,
        *args,
        **kwargs
    ) -> Any:
        """Execute function with automatic retry and model fallback."""
        
        available_models = self.MODEL_FALLBACKS.get(primary_model, [primary_model])
        last_error = None
        
        for attempt in range(self.RETRY_CONFIG["max_retries"] + 1):
            for model in available_models:
                # Skip if circuit breaker is open
                if self.is_circuit_open(model):
                    print(f"⏭️ Skipping {model} - circuit breaker open")
                    continue
                
                try:
                    # Inject model into kwargs if needed
                    if 'model' in kwargs or 'model_name' in kwargs:
                        kwargs['model'] = model
                    
                    print(f"🔄 Attempt {attempt + 1}: Calling with {model}")
                    
                    result = await asyncio.wait_for(
                        user_function(*args, **kwargs),
                        timeout=30.0
                    )
                    
                    self.record_success(model)
                    return result
                    
                except asyncio.TimeoutError:
                    error_msg = f"Timeout calling {model}"
                    print(f"⏱️ {error_msg}")
                    self.record_error(model, asyncio.TimeoutError())
                    last_error = asyncio.TimeoutError(error_msg)
                    break  # Try next model
                    
                except ConnectionError as e:
                    error_msg = f"ConnectionError calling {model}: {str(e)}"
                    print(f"🔌 {error_msg}")
                    self.record_error(model, e)
                    last_error = e
                    
                    # Apply backoff before retry
                    if attempt < self.RETRY_CONFIG["max_retries"]:
                        delay = self.get_backoff_delay(attempt)
                        print(f"⏳ Waiting {delay:.2f}s before retry...")
                        await asyncio.sleep(delay)
                    
                except Exception as e:
                    print(f"❌ Unexpected error with {model}: {type(e).__name__}: {str(e)}")
                    self.record_error(model, e)
                    last_error = e
                    break  # Try next model
        
        # All models and retries exhausted
        raise RuntimeError(
            f"All retry attempts failed. Last error: {type(last_error).__name__}: {str(last_error)}"
        ) from last_error

Example usage in a LangGraph node

async def robust_llm_node(state: dict, error_handler: HolySheepErrorHandler): """Example node with robust error handling.""" llm = ChatOpenAI( model="gpt-4.1", api_key="YOUR_HOLYSHEEP_API_KEY", base_url="https://api.holysheep.ai/v1" ) async def llm_call(): return await llm.ainvoke([ HumanMessage(content=state.get("query", "")) ]) try: response = await error_handler.call_with_fallback( llm_call, primary_model="gpt-4.1" ) return {"response": response.content, "status": "success"} except Exception as e: print(f"🚨 All fallback attempts exhausted: {e}") return {"response": None, "status": "failed", "error": str(e)}

Test the error handler

async def test_error_handling(): handler = HolySheepErrorHandler("YOUR_HOLYSHEEP_API_KEY") # Simulate some calls for i in range(5): try: result = await handler.call_with_fallback( lambda: asyncio.sleep(0.1) or {"status": "ok"}, primary_model="deepseek-v3.2" ) print(f"Call {i+1}: {result}") except Exception as e: print(f"Call {i+1} failed: {e}")

asyncio.run(test_error_handling())

Implementing State Inspection and Breakpoints

One of the most powerful debugging techniques is being able to pause execution and inspect state at any point in your graph. Let's implement a sophisticated breakpoint system.

from typing import Any, Dict, Callable, Optional
from dataclasses import dataclass
from enum import Enum
import ipdb  # Interactive debugger - install with: pip install ipdb

class BreakpointType(Enum):
    BEFORE_NODE = "before_node"
    AFTER_NODE = "after_node"
    ON_CONDITION = "on_condition"
    ON_ERROR = "on_error"

@dataclass
class Breakpoint:
    """Defines a debugging breakpoint."""
    bp_type: BreakpointType
    target: str  # Node name, condition function, or "any"
    callback: Optional[Callable] = None
    condition: Optional[Callable[[Dict], bool]] = None
    auto_continue: bool = False  # If True, don't actually pause

class StateBreakpointDebugger:
    """Interactive debugger for LangGraph state inspection."""
    
    def __init__(self):
        self.breakpoints: Dict[str, Breakpoint] = {}
        self.watch_variables: list = []
        self.execution_history: list = []
        self.is_active = True
        
    def set_breakpoint(
        self,
        node_name: str,
        bp_type: BreakpointType = BreakpointType.BEFORE_NODE,
        condition: Optional[Callable[[Dict], bool]] = None,
        callback: Optional[Callable] = None
    ):
        """Register a breakpoint at a specific node."""
        bp_id = f"{bp_type.value}:{node_name}"
        
        self.breakpoints[bp_id] = Breakpoint(
            bp_type=bp_type,
            target=node_name,
            condition=condition,
            callback=callback
        )
        
        print(f"🔴 Breakpoint set: {bp_id}")
    
    def watch(self, *variable_names: str):
        """Watch specific state variables for changes."""
        self.watch_variables.extend(variable_names)
        print(f"👁️ Watching variables: {variable_names}")
    
    def _inspect_state(self, node_name: str, state: Dict[str, Any], 
                       context: str = "entry"):
        """Display state for debugging."""
        print(f"\n{'='*60}")
        print(f"🔍 STATE INSPECTION at {node_name} ({context})")
        print(f"{'='*60}")
        
        # Show watched variables if any
        if self.watch_variables:
            print("\n📌 Watched Variables:")
            for var in self.watch_variables:
                value = state.get(var, "")
                print(f"   {var}: {value}")
        
        # Show full state with formatting
        print("\n📋 Complete State:")
        for key, value in state.items():
            display = str(value)
            if len(display) > 200:
                display = display[:200] + "..."
            marker = "👁️" if key in self.watch_variables else "  "
            print(f"   {marker} {key}: {display}")
        
        # Show state size
        state_size = len(str(state))
        print(f"\n💾 State size: {state_size:,} bytes")
        
        self.execution_history.append({
            "node": node_name,
            "context": context,
            "state_keys": list(state.keys()),
            "timestamp": datetime.now().isoformat()
        })
    
    def _handle_breakpoint(self, bp: Breakpoint, state: Dict[str, Any]) -> bool:
        """Process breakpoint hit."""
        
        # Check condition
        if bp.condition and not bp.condition(state):
            return True  # Continue execution
        
        # Run callback if provided
        if bp.callback:
            bp.callback(state)
        
        # Auto-continue mode
        if bp.auto_continue:
            print(f"⏩ Auto-continue enabled, skipping pause")
            return True
        
        # Interactive debugging with ipdb
        print(f"\n🛑 Breakpoint hit: {bp.bp_type.value} at {bp.target}")
        
        # Offer options
        print("\nOptions:")
        print("  c - Continue")
        print("  n - Next (run until next breakpoint)")
        print("  s - Step into")
        print("  p  - Print variable")
        print("  w - Show watched variables")
        print("  h - Show full state")
        print("  q - Quit session")
        
        # In production, you might use ipdb.set_trace() here
        # For automated testing, we just continue
        return True  # Continue execution
    
    def wrap_node(self, node_func: Callable, node_name: str) -> Callable:
        """Wrap a node function with breakpoint capabilities."""
        
        async def wrapped_node(state: Dict[str, Any], config: Optional[Dict] = None):
            # Check for BEFORE_NODE breakpoints
            bp_id = f"{BreakpointType.BEFORE_NODE.value}:{node_name}"
            if bp_id in self.breakpoints:
                self._inspect_state(node_name, state, "before")
                self._handle_breakpoint(self.breakpoints[bp_id], state)
            
            # Execute node
            try:
                result = await node_func(state, config)
                
                # Check for AFTER_NODE breakpoints
                bp_id_after = f"{BreakpointType.AFTER_NODE.value}:{node_name}"
                if bp_id_after in self.breakpoints:
                    self._inspect_state(node_name, result, "after")
                    self._handle_breakpoint(self.breakpoints[bp_id_after], result)
                
                return result
                
            except Exception as e:
                # Check for ON_ERROR breakpoints
                bp_id_error = f"{BreakpointType.ON_ERROR.value}:any"
                if bp_id_error in self.breakpoints:
                    self._inspect_state(node_name, state, "error")
                    print(f"\n💥 Error caught: {type(e).__name__}: {str(e)}")
                    self._handle_breakpoint(self.breakpoints[bp_id_error], state)
                raise
        
        return wrapped_node
    
    def generate_debug_report(self) -> Dict[str, Any]:
        """Generate comprehensive debugging report."""
        return {
            "total_nodes_visited": len(self.execution_history),
            "nodes": self.execution_history,
            "active_breakpoints": len(self.breakpoints),
            "breakpoint_details": [
                {
                    "id": bp_id,
                    "type": bp.bp_type.value,
                    "target": bp.target,
                    "has_condition": bp.condition is not None,
                    "auto_continue": bp.auto_continue
                }
                for bp_id, bp in self.breakpoints.items()
            ],
            "watched_variables": self.watch_variables
        }

Usage example

def create_debugged_graph(): """Example: Creating a graph with debugging enabled.""" debugger = StateBreakpointDebugger() # Set breakpoints debugger.set_breakpoint( "understanding", BreakpointType.BEFORE_NODE, callback=lambda s: print(f"📥 Query received: {s.get('query', '')[:50]}") ) debugger.watch("messages", "step_count", "context") # Create your graph class State(TypedDict): messages: List query: str step_count: int context: dict def understanding(state: State) -> State: return {**state, "step_count": 1, "context": {"understood": True}} def reasoning(state: State) -> State: return {**state, "step_count": 2, "context": {"reasoned": True}} workflow = StateGraph(State) workflow.add_node("understanding", debugger.wrap_node(understanding, "understanding")) workflow.add_node("reasoning", debugger.wrap_node(reasoning, "reasoning")) workflow.set_entry_point("understanding") workflow.add_edge("understanding", "reasoning") workflow.add_edge("reasoning", END) return workflow.compile(), debugger

Run debugged graph