← Back to DevBytes

Why Your AI Agent Keeps Crashing: Memory, Context, and State Management

Why Your AI Agent Keeps Crashing: Memory, Context, and State Management

You deploy your AI agent, it works beautifully for the first few requests, and then—silence. A 500 error. A timeout. A garbled response that makes no sense. If this sounds familiar, you're not alone. The root cause almost always traces back to three interconnected systems: memory, context, and state management. This tutorial will walk you through each one, show you why they fail, and give you production-ready patterns to keep your agent running reliably.

1. Understanding the Three Pillars

Before diving into code, let's define precisely what each term means in the context of AI agents—because conflating them is the first source of bugs.

1.1 Memory: The Agent's Long-Term Knowledge

Memory is anything the agent retains across sessions or conversations. It can be:

Memory is persistent. It survives restarts. The crash happens when memory retrieval is too slow, too large, or returns irrelevant data that pollutes the next step.

1.2 Context: The Agent's Working Window

Context is the information the model can see right now—the concatenation of system prompts, conversation history, retrieved documents, tool outputs, and reasoning traces all stuffed into a single prompt. Every LLM has a context window limit (e.g., 128K tokens for GPT-4 Turbo, 200K for Claude 3.5 Sonnet). When you exceed it, the model either truncates silently, refuses with an error, or produces degraded output.

1.3 State: The Agent's Current Position in a Workflow

State answers: "Where am I right now in this multi-step process?" It tracks which tools have been called, what decisions have been made, what's pending, and whether the agent is waiting for user input. Unlike context, state is often structured (a JSON object, a finite state machine) and doesn't need to be fully loaded into the prompt.

Here's the critical insight: memory overload crashes context, context corruption breaks state, and lost state forces the agent to repeat itself endlessly until it exhausts the context window. They form a vicious cycle.

2. Why Your Agent Crashes: The Failure Patterns

2.1 Context Window Overflow

The most common crash. You naively append every conversation turn, every retrieved document, and every tool result to the prompt. Around message 20, the agent starts producing truncated JSON, missing function call arguments, or simply times out.

# THE NAIVE APPROACH THAT WILL CRASH
prompt = system_prompt
for turn in conversation_history:
    prompt += f"User: {turn.user}\nAssistant: {turn.assistant}\n"
prompt += f"User: {current_message}\n"

# By message 30 with retrieved docs, you're at 150K tokens on a 128K model
# The API returns: "context_length_exceeded" or silently truncates early messages
response = llm.complete(prompt)  # BOOM

2.2 Memory Retrieval Poisoning

You store every interaction in a vector database and retrieve "relevant" chunks before each call. But similarity search isn't perfect. You pull in 15 "relevant" memories that are actually noise. The model now has to reason through irrelevant data, gets confused, and produces a wrong tool call that puts the agent into an unreachable state.

# POISONED RETRIEVAL
memories = vector_store.similarity_search(user_query, k=20)
memory_text = "\n".join([m.content for m in memories])

# Now your prompt is 60% irrelevant memories, 30% conversation, 10% instructions
# The model loses track of what it's supposed to do
prompt = f"{system_prompt}\n\nRelevant Memories:\n{memory_text}\n\nConversation:\n{history}\n\nUser: {user_query}"
# Agent responds with a hallucinated tool name that doesn't exist

2.3 State Drift and Orphaned Actions

Your agent is in the middle of a booking flow: it has asked for dates, the user provided them, and now it needs to ask for passenger count. But the conversation history got truncated due to context overflow, so the agent "forgot" it already asked for dates and asks again. The user gets frustrated, the conversation grows longer, the context fills faster, and the agent crashes harder. This is the death spiral.

# STATE DRIFT WITHOUT PERSISTENCE
# Turn 1
agent: "What dates would you like to book?"
# Turn 2  
user: "March 10-15"
# Turn 3 - but context was truncated, losing Turn 1
agent: "Sure! First, what dates would you like to book?"  # ASKS AGAIN
# User gets angry, types a long complaint, context overflows, agent crashes

3. How to Fix It: Practical Implementations

3.1 Context Management: Sliding Windows with Summarization

Stop appending everything. Use a sliding window combined with progressive summarization. Keep only the last N turns in raw form, and compress older turns into a growing summary.

class ContextManager:
    def __init__(self, max_recent_turns=10, summary_trigger=15):
        self.recent_turns = []  # Last N turns in raw form
        self.summary = ""       # Compressed history of everything older
        self.max_recent_turns = max_recent_turns
        self.summary_trigger = summary_trigger
    
    def add_turn(self, user_msg: str, assistant_msg: str):
        self.recent_turns.append({
            "user": user_msg,
            "assistant": assistant_msg
        })
        
        # When recent turns exceed trigger, compress oldest into summary
        if len(self.recent_turns) > self.summary_trigger:
            # Take turns that will be evicted
            to_summarize = self.recent_turns[:-self.max_recent_turns]
            new_summary = self._summarize_turns(to_summarize)
            
            # Merge with existing summary
            if self.summary:
                self.summary = self._merge_summaries(self.summary, new_summary)
            else:
                self.summary = new_summary
            
            # Keep only the recent window
            self.recent_turns = self.recent_turns[-self.max_recent_turns:]
    
    def _summarize_turns(self, turns: list) -> str:
        prompt = f"""Summarize this conversation segment into a compact paragraph.
        Preserve: decisions made, facts shared, actions taken, pending items.
        Discard: pleasantries, filler, repeated information.
        
        Segment:
        {self._format_turns(turns)}
        """
        return llm.complete(prompt, max_tokens=300)
    
    def _merge_summaries(self, old: str, new: str) -> str:
        prompt = f"""Merge these two conversation summaries into one cohesive summary.
        Eliminate duplicates. Preserve chronological order of key events.
        
        Existing Summary:
        {old}
        
        New Segment Summary:
        {new}
        """
        return llm.complete(prompt, max_tokens=400)
    
    def build_context(self, system_prompt: str, retrieved_knowledge: str = "") -> str:
        parts = [system_prompt]
        
        if self.summary:
            parts.append(f"## Conversation Summary (Earlier)\n{self.summary}")
        
        if retrieved_knowledge:
            parts.append(f"## Relevant Knowledge\n{retrieved_knowledge}")
        
        if self.recent_turns:
            parts.append("## Recent Conversation\n" + self._format_turns(self.recent_turns))
        
        return "\n\n".join(parts)
    
    def _format_turns(self, turns: list) -> str:
        return "\n".join([f"User: {t['user']}\nAssistant: {t['assistant']}" for t in turns])

# Usage
ctx = ContextManager(max_recent_turns=10, summary_trigger=15)
ctx.add_turn("I need to book a flight", "Sure! Where are you flying to?")
ctx.add_turn("New York to London", "Got it. What dates?")
# ... many turns later, context stays compact
prompt = ctx.build_context(system_prompt, retrieved_knowledge="")
# prompt is consistently ~8-12K tokens regardless of conversation length

3.2 Memory: Tiered Retrieval with Relevance Filtering

Don't dump raw vector search results into context. Implement a tiered memory system with strict filtering. Use semantic search to find candidates, then use an LLM-based filter to decide what actually matters for the current task.

class TieredMemory:
    def __init__(self, vector_store, relational_store):
        self.vector_store = vector_store      # For semantic search
        self.relational_store = relational_store  # For exact lookups (user_id, dates)
    
    def retrieve(self, query: str, user_id: str, max_tokens_budget: int = 2000) -> str:
        # Tier 1: Exact relational lookups (fast, deterministic)
        user_profile = self.relational_store.get(f"user:{user_id}:profile")
        recent_actions = self.relational_store.get_range(
            f"user:{user_id}:actions", 
            last_n=5
        )
        
        # Tier 2: Semantic search candidates
        candidates = self.vector_store.similarity_search(
            query, 
            filter={"user_id": user_id},
            k=20
        )
        
        # Tier 3: Relevance filter using a cheap model
        relevant = []
        for candidate in candidates:
            # Quick classifier: is this memory relevant to the query?
            relevance_score = self._check_relevance(query, candidate.content)
            if relevance_score > 0.6:
                relevant.append(candidate)
        
        # Tier 4: Rank and truncate to token budget
        relevant.sort(key=lambda x: x.relevance_score, reverse=True)
        
        result_parts = []
        token_count = 0
        
        if user_profile:
            result_parts.append(f"[User Profile] {user_profile}")
            token_count += self._estimate_tokens(user_profile)
        
        for mem in relevant:
            mem_tokens = self._estimate_tokens(mem.content)
            if token_count + mem_tokens > max_tokens_budget:
                break
            result_parts.append(f"[Memory] {mem.content}")
            token_count += mem_tokens
        
        return "\n".join(result_parts)
    
    def _check_relevance(self, query: str, memory: str) -> float:
        prompt = f"""Rate how relevant this memory is to the query on a scale of 0-1.
        Query: {query}
        Memory: {memory[:500]}
        Respond with only a number like 0.85"""
        try:
            return float(llm.complete(prompt, max_tokens=5))
        except:
            return 0.0
    
    def _estimate_tokens(self, text: str) -> int:
        # Rough estimate: 1 token ≈ 4 characters for English
        return len(text) // 4

# Usage
memory = TieredMemory(vector_store, redis_client)
relevant_context = memory.retrieve(
    query="What's the refund policy for my last order?",
    user_id="user_123",
    max_tokens_budget=2000
)
# Only truly relevant memories make it into the prompt

3.3 State Management: Finite State Machines with Checkpointing

State must be explicit, persisted, and recoverable. Model the agent's workflow as a finite state machine. At every transition, checkpoint the state to a durable store. If the agent crashes mid-flow, it resumes exactly where it left off—not from the beginning.

from enum import Enum
from typing import Optional, Dict, Any
import json
import redis

class BookingState(str, Enum):
    IDLE = "idle"
    GATHERING_ORIGIN = "gathering_origin"
    GATHERING_DESTINATION = "gathering_destination"
    GATHERING_DATES = "gathering_dates"
    GATHERING_PASSENGERS = "gathering_passengers"
    CONFIRMING = "confirming"
    BOOKING = "booking"
    COMPLETED = "completed"
    FAILED = "failed"

class StateMachine:
    def __init__(self, session_id: str, persistence: redis.Redis):
        self.session_id = session_id
        self.persistence = persistence
        self.state = self._load_state()
    
    def _load_state(self) -> Dict[str, Any]:
        raw = self.persistence.get(f"state:{self.session_id}")
        if raw:
            return json.loads(raw)
        return {
            "current": BookingState.IDLE,
            "collected_data": {},
            "retry_count": 0,
            "last_transition": None,
            "checkpoint_timestamp": None
        }
    
    def _save_state(self):
        self.state["checkpoint_timestamp"] = time.time()
        self.persistence.set(
            f"state:{self.session_id}",
            json.dumps(self.state),
            ex=3600  # 1 hour TTL
        )
    
    def transition(self, to: BookingState, data_update: Optional[Dict] = None):
        """Atomically transition state and checkpoint."""
        previous = self.state["current"]
        
        if not self._is_valid_transition(previous, to):
            raise ValueError(f"Invalid transition: {previous} -> {to}")
        
        self.state["current"] = to
        self.state["last_transition"] = f"{previous.value} -> {to.value}"
        self.state["retry_count"] = 0  # Reset on successful transition
        
        if data_update:
            self.state["collected_data"].update(data_update)
        
        self._save_state()
    
    def _is_valid_transition(self, from_state: BookingState, to: BookingState) -> bool:
        transitions = {
            BookingState.IDLE: [BookingState.GATHERING_ORIGIN, BookingState.GATHERING_DESTINATION],
            BookingState.GATHERING_ORIGIN: [BookingState.GATHERING_DESTINATION],
            BookingState.GATHERING_DESTINATION: [BookingState.GATHERING_DATES],
            BookingState.GATHERING_DATES: [BookingState.GATHERING_PASSENGERS],
            BookingState.GATHERING_PASSENGERS: [BookingState.CONFIRMING],
            BookingState.CONFIRMING: [BookingState.BOOKING, BookingState.GATHERING_DATES],  # Can go back
            BookingState.BOOKING: [BookingState.COMPLETED, BookingState.FAILED],
            BookingState.FAILED: [BookingState.IDLE, BookingState.CONFIRMING],
            BookingState.COMPLETED: [BookingState.IDLE]
        }
        return to in transitions.get(from_state, [])
    
    def increment_retry(self):
        self.state["retry_count"] += 1
        self._save_state()
        
        if self.state["retry_count"] > 3:
            self.state["current"] = BookingState.FAILED
            self._save_state()
            return False  # Signal that we've exceeded retry limit
        return True
    
    def get_resume_prompt(self) -> str:
        """Generate a prompt that tells the agent exactly where it left off."""
        current = self.state["current"]
        collected = self.state["collected_data"]
        
        resume_instructions = {
            BookingState.GATHERING_ORIGIN: "You are collecting the departure city. Ask the user for their origin.",
            BookingState.GATHERING_DESTINATION: f"You have origin: {collected.get('origin')}. Now ask for the destination.",
            BookingState.GATHERING_DATES: f"You have origin: {collected.get('origin')}, destination: {collected.get('destination')}. Ask for travel dates.",
            BookingState.GATHERING_PASSENGERS: f"You have the route and dates. Now ask for the number of passengers.",
            BookingState.CONFIRMING: f"Summarize the booking details and ask for confirmation: {json.dumps(collected)}",
            BookingState.FAILED: "Apologize and offer to start over or resume from where the error occurred."
        }
        
        return resume_instructions.get(current, "Determine the next appropriate step.")

# Usage with the agent loop
async def agent_loop(session_id: str, user_input: str):
    redis_client = redis.Redis()
    sm = StateMachine(session_id, redis_client)
    
    try:
        # Build context INCLUDING state resume instructions
        resume_prompt = sm.get_resume_prompt()
        
        context = ctx_manager.build_context(
            system_prompt=f"You are a flight booking agent.\nCurrent Step: {resume_prompt}",
            retrieved_knowledge=memory.retrieve(user_input, session_id)
        )
        
        response = await llm.complete_async(context + f"\n\nUser: {user_input}")
        
        # Parse response for state transitions
        parsed = parse_agent_response(response)
        
        if parsed.intent == "provide_info":
            if "origin" in parsed.data:
                sm.transition(BookingState.GATHERING_DESTINATION, parsed.data)
            elif "destination" in parsed.data:
                sm.transition(BookingState.GATHERING_DATES, parsed.data)
            # ... etc
        
        return response
        
    except Exception as e:
        can_retry = sm.increment_retry()
        if can_retry:
            # Retry with the same state
            return await agent_loop(session_id, user_input)
        else:
            return "I'm having trouble. Let's start over. What can I help you with?"

4. Putting It All Together: The Resilient Agent Architecture

Here's a complete agent loop that integrates all three systems. This is the pattern that prevents crashes in production.

import asyncio
import time
import logging
from typing import Optional

logger = logging.getLogger(__name__)

class ResilientAgent:
    def __init__(
        self,
        context_manager: ContextManager,
        memory: TieredMemory,
        state_machine: StateMachine,
        llm_client,
        max_context_tokens: int = 100_000,
        safety_margin: int = 10_000
    ):
        self.ctx = context_manager
        self.memory = memory
        self.state = state_machine
        self.llm = llm_client
        self.max_context_tokens = max_context_tokens
        self.safety_margin = safety_margin
    
    async def handle_message(self, user_id: str, session_id: str, message: str) -> str:
        """Process a user message with full resilience patterns."""
        
        # 1. Load state checkpoint
        resume_instructions = self.state.get_resume_prompt()
        collected_data = self.state.state.get("collected_data", {})
        
        # 2. Retrieve memories within strict budget
        memory_budget = 2000  # tokens for memories
        relevant_memories = self.memory.retrieve(
            query=message,
            user_id=user_id,
            max_tokens_budget=memory_budget
        )
        
        # 3. Build context with explicit token counting
        system_prompt = self._build_system_prompt(resume_instructions, collected_data)
        base_prompt = self.ctx.build_context(system_prompt, relevant_memories)
        
        current_prompt = f"{base_prompt}\n\nUser: {message}"
        
        # 4. Pre-flight token check
        estimated_tokens = self._estimate_tokens(current_prompt)
        available = self.max_context_tokens - self.safety_margin
        
        if estimated_tokens > available:
            logger.warning(f"Token overflow risk: {estimated_tokens} > {available}")
            # Aggressive context trimming
            self.ctx.max_recent_turns = max(3, self.ctx.max_recent_turns - 3)
            current_prompt = self._rebuild_with_trimmed_context(
                system_prompt, relevant_memories, message
            )
        
        # 5. Execute with timeout and retry
        try:
            response = await asyncio.wait_for(
                self.llm.complete_async(current_prompt),
                timeout=30.0
            )
            
            # 6. Validate response structure
            if not self._validate_response(response):
                raise ValueError("Invalid response structure")
            
            # 7. Update state based on response
            self._update_state_from_response(response)
            
            # 8. Store this turn in memory
            self._store_turn(user_id, session_id, message, response)
            
            # 9. Update context manager
            self.ctx.add_turn(message, response)
            
            return response
            
        except asyncio.TimeoutError:
            logger.error("LLM timeout")
            return await self._handle_error(user_id, session_id, "timeout")
        except Exception as e:
            logger.error(f"Agent error: {e}")
            return await self._handle_error(user_id, session_id, str(e))
    
    def _build_system_prompt(self, resume_instructions: str, collected_data: dict) -> str:
        return f"""You are a helpful AI assistant.
        
        ## Current Task
        {resume_instructions}
        
        ## Data Collected So Far
        {json.dumps(collected_data, indent=2) if collected_data else "None yet"}
        
        ## Important Rules
        - Stay focused on the current task step
        - Do not repeat questions already answered in Collected Data
        - If you encounter an error, clearly state what went wrong
        - Never invent information not provided by the user"""
    
    def _estimate_tokens(self, text: str) -> int:
        # Conservative estimate: 1 token per 3.5 characters for English
        return len(text) // 3
    
    def _validate_response(self, response: str) -> bool:
        # Check for truncation markers
        truncation_indicators = [
            response.endswith("..."),
            len(response) < 10,
            response.count("{") != response.count("}"),  # Unbalanced JSON
        ]
        return not any(truncation_indicators)
    
    def _update_state_from_response(self, response: str):
        """Parse response and update state machine."""
        # Extract structured data from response using regex or JSON parsing
        import re
        
        # Look for JSON blocks
        json_match = re.search(r'\{[\s\S]*\}', response)
        if json_match:
            try:
                data = json.loads(json_match.group(0))
                if "action" in data:
                    # Map actions to state transitions
                    action_to_state = {
                        "collect_origin": BookingState.GATHERING_ORIGIN,
                        "collect_destination": BookingState.GATHERING_DESTINATION,
                        "collect_dates": BookingState.GATHERING_DATES,
                        "collect_passengers": BookingState.GATHERING_PASSENGERS,
                        "confirm": BookingState.CONFIRMING,
                        "book": BookingState.BOOKING,
                    }
                    new_state = action_to_state.get(data["action"])
                    if new_state:
                        self.state.transition(new_state, data.get("collected", {}))
            except json.JSONDecodeError:
                pass
    
    def _store_turn(self, user_id: str, session_id: str, user_msg: str, assistant_msg: str):
        """Persist this interaction to both vector and relational memory."""
        # Store full turn in relational store
        self.memory.relational_store.push(
            f"user:{user_id}:actions",
            json.dumps({"user": user_msg, "assistant": assistant_msg, "ts": time.time()})
        )
        # Store embedding for semantic retrieval
        embedding = embed(user_msg)
        self.memory.vector_store.add(
            text=f"User: {user_msg}\nAssistant: {assistant_msg}",
            embedding=embedding,
            metadata={"user_id": user_id, "session_id": session_id}
        )
    
    def _rebuild_with_trimmed_context(self, system_prompt: str, memories: str, message: str) -> str:
        """Emergency context rebuild when near token limit."""
        # Keep only 3 most recent turns
        self.ctx.recent_turns = self.ctx.recent_turns[-3:]
        base = self.ctx.build_context(system_prompt, memories)
        return f"{base}\n\nUser: {message}"
    
    async def _handle_error(self, user_id: str, session_id: str, error_type: str) -> str:
        """Graceful error recovery."""
        can_retry = self.state.increment_retry()
        
        if can_retry:
            # Wait briefly, then allow retry
            await asyncio.sleep(1)
            return "I encountered a hiccup. Could you repeat your last request?"
        else:
            # Reset state machine
            self.state.state["current"] = BookingState.IDLE
            self.state.state["retry_count"] = 0
            self.state._save_state()
            return "I apologize—I'm having persistent issues. Let's start fresh. How can I help you?"

5. Best Practices and Design Principles

5.1 Token Budgeting Is Non-Negotiable

Never guess. Always estimate tokens before calling the LLM. Allocate explicit budgets for each component:

5.2 Checkpoint Before Every LLM Call

State must be persisted before you invoke the model, not after. If the call times out or crashes, you can resume from the exact pre-call state. This is the single most important reliability pattern.

5.3 Use Structured Outputs for State Transitions

Don't rely on natural language parsing to determine state changes. Force the model to emit structured data (JSON, function calls) that explicitly signals state transitions. This eliminates ambiguity.

# Good: Structured output forces explicit state signaling
system_prompt = """...
When you have collected a piece of information, output:
{
  "action": "collect_origin",
  "collected": {"origin": "New York"},
  "next_question": "Where would you like to fly to?"
}"""

5.4 Implement Graceful Degradation

When context is nearly full, don't crash—degrade. Reduce the conversation window to the last 3 turns. Drop lower-priority memories. Simplify the system prompt. The agent may be less context-aware, but it won't crash.

5.5 Test with Token Counting Enabled

Most developers test with short conversations and never hit the limit. Use a token counter in your test harness and simulate 50+ turn conversations. Verify that your context manager keeps the prompt size stable.

# Token-aware test harness
def test_long_conversation():
    agent = ResilientAgent(...)
    
    for i in range(100):
        prompt = agent._build_full_prompt(f"Test message {i}")
        token_count = count_tokens(prompt)
        assert token_count < agent.max_context_tokens - agent.safety_margin, \
            f"Token overflow at turn {i}: {token_count} tokens"
    
    print("Passed: Context remains stable over 100 turns")

5.6 Separate Memory Storage from Memory Retrieval

Storage should be write-only and fire-and-forget (async). Retrieval should be read-optimized with strict filtering. Never let retrieval block the main agent loop.

5.7 Monitor the Death Spiral Indicators

Set up alerts for: retry count spikes, average tokens per call creeping up, state machine reset frequency, and time-to-first-token increasing. These are early warnings that your context management is failing.

Conclusion

AI agents crash not because LLMs are unreliable, but because developers treat memory, context, and state as an afterthought. The fix is architectural: implement progressive summarization to keep context bounded, tiered memory retrieval with relevance filtering to prevent noise pollution, and persistent finite state machines with checkpointing so the agent never loses its place. When these three systems work in concert—with explicit token budgets, structured state transitions, and graceful degradation—your agent will handle conversations of any length without crashing. The patterns in this tutorial are battle-tested. Start with the ResilientAgent class above, adapt it to your framework, and you'll eliminate the most common production failures overnight.

— Ad —

Google AdSense will appear here after approval

← Back to all articles