Building an AI agent on a local machine is straightforward. You can run local AI models on your laptop for development, write a loop, define three tools, hook them up to a model, and watch it solve a mocked task. But when you deploy this system to production, things fall apart. Concurrency, long sessions, and messy real-world inputs cause agents to drift, hallucinate tool arguments, and run out of context space.
These failures do not look like typical software bugs. They do not throw clean stack traces. Instead, they manifest as silent degradation: an agent that worked perfectly on step three starts repeating itself on step ten, or it calls an API with a payload that violates the schema it knew five minutes ago.
Here is how to analyze and fix these issues in production systems.
Concurrency and State Drift
In a multi-agent system, state drift occurs when an agent's internal view of the world mismatch the actual environment. This is common when multiple agents run concurrently or when tasks take minutes to complete. If Agent A fetches a database record, makes a slow LLM call, and then writes back an update, Agent B may have modified that same record in the meantime.
If you pass the entire state block inside the LLM prompt, the model will act on outdated data. You cannot rely on the LLM to manage its own state consistency.
To prevent this, isolate state updates behind a versioned, optimistic-locking store. Do not let agents write directly to a shared context object. Instead, use a centralized state coordinator that validates updates before committing them.
Here is a thread-safe state manager in Python that prevents concurrent overwrite issues:
import threading
from typing import Dict, Any, Optional
class StateConflictError(Exception):
pass
class AgentStateManager:
def __init__(self):
self._lock = threading.Lock()
self._store: Dict[str, Dict[str, Any]] = {}
def initialize_session(self, session_id: str, initial_data: dict):
with self._lock:
self._store[session_id] = {
"version": 1,
"data": initial_data
}
def get_state(self, session_id: str) -> Optional[dict]:
with self._lock:
session = self._store.get(session_id)
if not session:
return None
return {
"version": session["version"],
"data": session["data"].copy()
}
def update_state(self, session_id: str, expected_version: int, new_data: dict) -> int:
with self._lock:
session = self._store.get(session_id)
if not session:
raise ValueError("Session not found")
if session["version"] != expected_version:
raise StateConflictError(
f"Version mismatch. Expected {expected_version}, got {session['version']}"
)
session["data"].update(new_data)
session["version"] += 1
return session["version"]The E-Commerce Race Condition Scenario
Imagine a customer support agent. A user sends two messages in quick succession: "Cancel my order 1024" and "Actually, change the shipping address to NY first." If your application processes these messages concurrently using separate agent instances, both instances fetch the current state of order 1024.
Instance A reads the order state, sees it is "Processing," and sends a request to the cancellation API. Instance B reads the order state, sees it is "Processing," and calls the address change API.
If Instance A completes first, the order is cancelled. Instance B then attempts to change the shipping address of a cancelled order, which fails or corrupts the database.
By using version checks, Instance B's write is rejected because the state version changed from 1 to 2 when Instance A committed the cancellation. Instance B must re-read the state, see the order is cancelled, and tell the user that the address cannot be changed.
Tool Hallucination and Schema Drift
LLMs do not reliably follow JSON schemas, especially when context windows grow or when they use small, open-source models. The model will omit required fields, pass strings instead of integers, or invent parameters that do not exist in your API.
If you pass raw LLM outputs straight to your API client, you will run into unhandled exceptions. Worse, the agent might receive a raw stack trace back as a tool response, which confuses the model and triggers further hallucinations.
You need a validation layer between the LLM and your tools. This layer must catch parsing errors, validate schemas, and attempt auto-correction before hitting the actual API.
from pydantic import BaseModel, ValidationError
from typing import Callable, Dict, Any, Tuple
import json
class ToolExecutor:
def __init__(self):
self._tools: Dict[str, Tuple[Callable, BaseModel]] = {}
def register_tool(self, name: str, func: Callable, schema: BaseModel):
self._tools[name] = (func, schema)
def execute(self, tool_name: str, raw_arguments: str) -> str:
if tool_name not in self._tools:
return f"Error: Tool '{tool_name}' does not exist."
func, schema = self._tools[tool_name]
try:
parsed_args = json.loads(raw_arguments)
except json.JSONDecodeError:
return "Error: Arguments must be valid JSON."
try:
validated_data = schema(**parsed_args)
except ValidationError as e:
error_messages = []
for error in e.errors():
loc = " -> ".join(str(x) for x in error["loc"])
error_messages.append(f"Field '{loc}': {error['msg']}")
return f"Error: Invalid arguments. Details:\n" + "\n".join(error_messages)
try:
result = func(**validated_data.model_dump())
return json.dumps({"status": "success", "result": result})
except Exception as e:
return json.dumps({"status": "failed", "error": str(e)})If validation fails, you feed the error message back to the LLM. Most models can correct their output on the next turn if the error message specifies exactly which field failed validation.
But do not let this retry loop run forever. Set a maximum retry limit (usually two attempts) before raising a hard error or routing the task to a fallback handler.
Keep Tool Schemas Flat
When defining tools, keep schemas simple. LLMs struggle with nested JSON objects. Instead of a nested structure like {"user": {"profile": {"id": 123}}}, flatten the inputs to {"user_id": 123}.
If a tool must accept complex data, write a wrapper function that accepts flat arguments and builds the nested structure internally before calling the API.
Memory Decay and Context Saturation
Most agent frameworks append every turn to the history list. As the session goes on, the context window fills up. This has three consequences:
- Latency increases because the model processes more tokens.
- Cost increases quadratically.
- The model loses focus on the initial system instructions.
You must manage memory actively. A production agent should use a tiered memory system, which requires careful benchmarking of AI agent memory systems:
- Working Memory: The system prompt, the immediate task goal, and the last 3-4 turns of interaction. This stays in the active context.
- Episodic Memory: A rolling summary of older turns. Instead of keeping raw tool outputs and long assistant responses, summarize them into a concise timeline.
- Semantic Memory: Long-term facts, database lookups, and user preferences stored in a vector database and pulled in via RAG only when needed.
Here is a memory manager that implements a sliding window with automatic summarization:
from typing import List, Dict
class MemoryManager:
def __init__(self, max_raw_turns: int = 4):
self.max_raw_turns = max_raw_turns
self.history: List[Dict[str, str]] = []
self.summary: str = ""
def add_message(self, role: str, content: str):
self.history.append({"role": role, "content": content})
def get_context(self, llm_summarizer_func: callable) -> List[Dict[str, str]]:
if len(self.history) <= self.max_raw_turns * 2:
return self.history
turns_to_summarize = self.history[:-self.max_raw_turns * 2]
turns_to_keep = self.history[-self.max_raw_turns * 2:]
new_summary = llm_summarizer_func(self.summary, turns_to_summarize)
self.summary = new_summary
compressed_history = [
{"role": "system", "content": f"Summary of previous actions: {self.summary}"}
]
compressed_history.extend(turns_to_keep)
self.history = [
{"role": "system", "content": f"Summary of previous actions: {self.summary}"}
] + turns_to_keep
return compressed_historyThis prevents the context window from growing indefinitely. The model only sees the summary of past events and the raw details of the current step.
Mitigating the Lost-in-the-Middle Problem
Large language models can process huge context windows, but their retrieval accuracy drops in the middle of the prompt. If you put your core instructions at the top, and then append 20,000 tokens of chat history, the model will forget the rules. It might start outputting raw markdown when you told it to only output JSON, or it might ignore safety guardrails.
To fix this, use a prompt compiler that splits the system instructions. Keep the core identity at the top, but inject operational rules (like output format and banned tools) at the bottom of the prompt, right before the final user message.
def compile_prompt(system_instructions: str, memory_context: List[dict], active_rules: str) -> List[dict]:
compiled = []
compiled.append({"role": "system", "content": system_instructions})
compiled.extend(memory_context)
compiled.append({"role": "system", "content": f"Operational Rules:\n{active_rules}"})
return compiledThis ensures that the model always reads the rules immediately before generating its next token.
Loop Detection and Escape Hatches
Agents frequently get stuck in loops. This happens when a tool returns an error, and the agent calls the same tool with the same arguments, expecting a different result. Or the agent alternates between two tools without making progress.
Without loop detection, an agent can run thousands of times, consuming resources until it hits a rate limit or a timeout.
You must track the history of actions and inputs. If you detect the same action-input pair more than twice, break the loop.
import hashlib
from typing import List, Tuple
class LoopDetector:
def __init__(self, window_size: int = 5, max_repeats: int = 2):
self.window_size = window_size
self.max_repeats = max_repeats
self.action_history: List[str] = []
def _hash_action(self, tool_name: str, arguments: str) -> str:
normalized_args = "".join(arguments.split())
action_str = f"{tool_name}:{normalized_args}"
return hashlib.sha256(action_str.encode()).hexdigest()
def record_and_check(self, tool_name: str, arguments: str) -> bool:
action_hash = self._hash_action(tool_name, arguments)
self.action_history.append(action_hash)
if len(self.action_history) > self.window_size:
self.action_history.pop(0)
occurrences = self.action_history.count(action_hash)
if occurrences > self.max_repeats:
return True
return FalseWhen record_and_check returns True, you must intercept the execution. Do not send the request to the LLM. Instead, return a system message like: "System: You have attempted this action multiple times without success. Try a different approach or stop." If it fails again, raise an exception to bubble up to a human operator. Keep in mind that manual gates are not foolproof, as humans miss security threats in agent commands under cognitive load.
Observability in Production Loops
Debugging agents in production is difficult because you cannot see the intermediate steps from standard application logs. When a user reports that an agent gave a wrong answer, you need to reconstruct the execution graph.
Every agent run must have a unique run ID. Every LLM call, tool call, and state transition within that run must be logged with parent-child relationships.
Log the following fields for every step:
run_id: The global ID for the user session.step_id: The identifier for this specific loop iteration.prompt_tokensandcompletion_tokens: To monitor cost and latency.input_state: The state of the system before the LLM ran.raw_llm_output: The unparsed response from the model.parsed_action: The tool name and arguments extracted.tool_response: The output returned by the tool.
If you store these logs in a structured format, you can run queries to find where agents fail. For example, you can query for runs that took more than ten steps or runs where a specific tool returned an error code.
Hard Timeouts and Resource Limits
An agent loop is a while True statement at its core. If the LLM never generates a stop token or a final answer, the loop will run until the server times out.
Set hard limits on two dimensions:
- Time: Maximum execution time per run (e.g., 60 seconds).
- Steps: Maximum number of LLM calls per run (e.g., 10 steps).
Implement these limits at the engine level, not inside the agent code. If the limit is reached, terminate the run, revert the state to the last known good version, and return a clean error message to the user.
By treating agent execution like untrusted code execution, you protect your infrastructure from runaway loops and unpredictable model behavior.



