INTRODUCTION: WHY YOUR AGENT IS LYING TO YOUThere is a particular kind of frustration that is unique to the age of agentic AI. It is not the frustration of a null pointer exception, which at least has the decency to point at a line number. It is not the frustration of a failed database query, which at least leaves a traceable error code. No, the frustration of debugging an AI agent is something altogether more existential. Your agent was supposed to book a flight, research a topic, or summarize a document. Instead, it decided to call the same search tool seventeen times in a row, hallucinated a tool that does not exist, and then confidently declared success while having accomplished absolutely nothing. Welcome to the frontier. Agentic AI systems are software programs that use large language models as their central reasoning engine. Unlike a traditional chatbot that simply responds to a single prompt, an agent perceives its environment, makes plans, takes actions using tools, observes the results of those actions, and then decides what to do next. This loop continues until the agent either achieves its goal or runs out of budget, patience, or sanity. The architecture is powerful. It is also, from a debugging standpoint, a spectacular challenge. The core problem is that every component of an agent introduces uncertainty. The LLM at the heart of the system is a probabilistic function: given the same input twice, it may produce two different outputs. The tools the agent calls may succeed, fail, time out, or return subtly wrong results. The memory system may retrieve the wrong context. The planning module may devise a strategy that looks reasonable on paper but falls apart in execution. And when all of these uncertain components are chained together in a multi-step loop, small errors compound into large failures in ways that are genuinely difficult to predict or trace. This article is your guide through that wilderness. We will examine every component of an agentic system, understand how each one can fail, and build up a comprehensive, practical toolkit for finding out exactly what went wrong and why. We will look at real code, real tools, and real strategies used by engineers in production today. By the end, you will have a mental model and a practical methodology that will let you approach even the most baffling agent misbehavior with calm, systematic confidence. Or at least with slightly less panic than before.
CHAPTER 1: ANATOMY OF AN AGENT - KNOW WHAT YOU ARE DEBUGGINGBefore you can debug something, you need to understand what it is. An agentic AI system is not a monolith. It is a composition of distinct components, each with its own responsibilities, its own failure modes, and its own debugging requirements. Treating the whole system as a black box is the single fastest way to ensure you will never find the bug. The five major components of a modern AI agent are the LLM core, the planning module, the memory system, the tool layer, and the reflection or self-evaluation loop. Let us walk through each one carefully.
THE LLM COREThe large language model is the brain of the agent. It receives a prompt that describes the agent's current situation - its goal, its history, the results of its last action, and the tools available to it - and it produces a response that specifies what the agent should do next. This response might be a piece of reasoning, a decision to call a tool, or a final answer to the user. The LLM core is the source of most of the non-determinism in the system. Even with temperature set to zero, different hardware, different model versions, and subtle differences in floating-point arithmetic can produce different outputs. At higher temperatures, the model samples from a probability distribution over possible next tokens, which means every call is essentially a roll of a weighted die. This is not a bug. It is a fundamental property of how these models work. But it is the first thing you need to internalize when you approach debugging. The LLM core can fail in several characteristic ways. It can hallucinate facts, inventing information that sounds plausible but is false. It can hallucinate tools, attempting to call a function that does not exist in its tool registry. It can misinterpret its instructions, following the letter of a prompt rather than its spirit. It can lose track of the goal over a long conversation, drifting toward tangential sub-tasks. And it can produce outputs in the wrong format, breaking the parsing logic that the agent framework depends on.
THE PLANNING MODULEThe planning module is responsible for breaking a complex goal into a sequence of smaller, manageable steps. In some agent architectures, this is a separate LLM call that produces an explicit plan before execution begins. In others, planning is implicit, woven into the reasoning that the LLM produces at each step of the action loop. Planning failures are often subtle. The agent may produce a plan that looks entirely reasonable but that is missing a critical step. It may plan steps in the wrong order, creating dependencies that cannot be satisfied. It may plan steps that are individually correct but that together do not achieve the stated goal. Or it may simply abandon the plan partway through execution when a tool call returns an unexpected result, pivoting to a new strategy without informing anyone.
THE MEMORY SYSTEMMemory in an agentic system comes in two flavors: short-term and long-term. Short-term memory is the context window of the LLM - the running conversation history that is passed to the model with every call. Long-term memory is an external store, typically a vector database or a key-value store, from which the agent retrieves relevant information using semantic search. The context window is finite. Modern LLMs can handle tens or even hundreds of thousands of tokens, but every token costs money and adds latency, and there is always a ceiling. When an agent runs for many steps, the conversation history grows, and eventually something must be summarized, truncated, or dropped. What gets dropped is not always what you would choose to drop. An agent that has forgotten a critical constraint from step two because it was truncated at step fifteen will behave in ways that appear completely irrational unless you know to look at the memory management layer. Long-term memory introduces its own failure modes. The retrieval step may return irrelevant documents if the embedding model does not capture the right semantic similarity. It may return outdated information if the store has not been updated. It may return too little context, leaving the agent without the information it needs, or too much context, drowning the relevant signal in noise.
THE TOOL LAYERTools are the agent's hands. They are the functions the agent can call to interact with the world: web search, code execution, database queries, API calls, file operations, and anything else the system designer has chosen to expose. The tool layer is where the agent's abstract reasoning meets concrete reality, and it is where a great many bugs live. Tool failures can be broadly categorized as selection failures, parameter failures, execution failures, and result interpretation failures. A selection failure occurs when the agent chooses the wrong tool for the job. A parameter failure occurs when the agent calls the right tool with the wrong arguments. An execution failure occurs when the tool itself fails - due to a network error, an API rate limit, or a bug in the tool implementation. A result interpretation failure occurs when the tool executes correctly but the agent misunderstands or misuses its output.
THE REFLECTION LOOPMany modern agent architectures include a reflection or self-evaluation step, in which the agent reviews its own output against a set of quality criteria before deciding whether to continue or to return a final answer. This is sometimes implemented as a separate "critic" LLM call that scores the agent's work and identifies problems, which are then fed back to a "generator" that revises the output. The reflection loop is powerful, but it introduces its own failure modes. A critic that is too lenient will approve bad outputs. A critic that is too strict will prevent the agent from ever finishing. A critic that hallucinates problems that do not exist will send the agent on unnecessary revision cycles. And a reflection loop without a hard iteration limit is a potential infinite loop waiting to happen. Understanding this anatomy is not just academic. When you are staring at a broken agent run at midnight, knowing which component to look at first is the difference between a thirty-minute fix and a three-hour debugging session.
CHAPTER 2: THE NATURE OF THE BEAST - UNDERSTANDING NON-DETERMINISMIf you have spent your career debugging traditional software, you have developed a set of intuitions that serve you well in that domain. You know that given the same inputs, a function will produce the same output. You know that you can set a breakpoint, inspect state, and replay execution. You know that bugs are reproducible. These intuitions are deeply ingrained, and they are almost entirely wrong when applied to agentic AI systems. Non-determinism in AI agents operates at multiple levels simultaneously, and understanding each level is essential for developing a debugging methodology that actually works.
NON-DETERMINISM AT THE MODEL LEVELThe most fundamental source of non-determinism is the LLM's sampling process. When a language model generates a response, it does not simply pick the most likely next token at each step. Instead, it samples from a probability distribution over the entire vocabulary. The temperature parameter controls how "peaked" this distribution is: at temperature zero, the model always picks the most likely token, which is as close to deterministic as these models get. At higher temperatures, less likely tokens have a greater chance of being selected, which increases creativity and diversity but also increases unpredictability. Even at temperature zero, however, true determinism is not guaranteed. Floating-point arithmetic on GPUs is not always perfectly reproducible across different hardware configurations, different batch sizes, or different versions of the underlying CUDA libraries. Model providers also update their models silently, meaning that a call to "gpt-4o" today may not produce the same output as a call to "gpt-4o" three months from now, even with identical inputs and a temperature of zero. This has a profound implication for debugging: you cannot simply run the same agent twice and expect to see the same behavior. A bug that manifests on one run may not appear on the next. A fix that appears to work may simply have gotten lucky on the test run.
NON-DETERMINISM AT THE TOOL LEVELTools introduce their own non-determinism. A web search tool will return different results on different days as the web changes. An API call may succeed on one attempt and fail with a rate limit error on the next. A database query may return different results if the underlying data has been updated between runs. Even a code execution tool may produce different results if it depends on the current time or on random number generation. This means that even if you could perfectly reproduce the LLM's outputs, you might still not be able to reproduce the agent's behavior, because the tool outputs that feed back into the LLM's context may have changed.
NON-DETERMINISM AT THE SYSTEM LEVELIn multi-agent systems, non-determinism compounds dramatically. When multiple agents are running concurrently, their interactions create emergent behaviors that are extremely difficult to predict from the behavior of any individual agent. The order in which agents complete their tasks, the messages they pass to each other, and the shared resources they compete for all introduce additional sources of variability. Research published in 2025 found that unstructured multi-agent networks can amplify errors up to 17.2 times compared to single-agent baselines. A small hallucination in one agent's output can propagate through the system, being treated as ground truth by downstream agents, until it has corrupted the entire task execution.
WHAT THIS MEANS FOR YOUR DEBUGGING STRATEGYThe non-deterministic nature of agentic AI systems means that traditional debugging approaches must be supplemented with new techniques. You cannot rely on being able to reproduce a bug on demand. You cannot set a breakpoint and inspect state at a specific moment in time. You cannot assume that a fix that works once will work consistently. Instead, you need to build your debugging strategy around three core principles. The first principle is comprehensive logging: if you cannot reproduce the bug, you need to have captured enough information during the original run to diagnose it after the fact. The second principle is statistical thinking: instead of asking "why did this specific run fail," you need to ask "what is the distribution of behaviors across many runs, and what does that distribution tell me about the underlying problem." The third principle is isolation: you need to be able to freeze individual components of the system and test them in isolation, so that you can determine which component is responsible for a given failure. We will build out all three of these principles in detail in the chapters that follow.
CHAPTER 3: A TAXONOMY OF AGENT FAILURES - ALL THE WAYS THINGS GO WRONGBefore you can fix a bug, you need to be able to name it. One of the most valuable things you can do as an agent developer is to build a mental taxonomy of the failure modes you are likely to encounter. This taxonomy serves as a diagnostic checklist: when an agent misbehaves, you can systematically work through the categories until you find the one that matches what you are seeing. Based on both research literature and hard-won production experience, the major failure categories for agentic AI systems are as follows.
GOAL DRIFTGoal drift occurs when the agent loses track of its original objective over the course of a long execution. This is one of the most insidious failure modes because the agent continues to work diligently - it is not stuck, it is not looping, it is not throwing errors - it is simply working toward the wrong goal. Goal drift typically happens in one of two ways. The first is context window pressure: as the conversation history grows, the original goal statement gets pushed further and further from the end of the context, and the LLM's attention mechanism gives it less weight. The agent begins to optimize for the most recent observations rather than the original objective. The second is sub-goal fixation: the agent becomes so focused on solving a particular sub-problem that it forgets the sub-problem was only a means to an end. Diagnosing goal drift requires examining the agent's reasoning traces over time and checking whether the stated goal in each step's reasoning matches the original objective. A well-designed logging system will make this trivial. A poorly designed one will make it nearly impossible.
INFINITE LOOPS AND THRASHINGInfinite loops in agentic systems are not the same as infinite loops in traditional software. There is no while(true) statement to find. Instead, the agent enters a behavioral loop: it takes an action, observes a result, reasons that it needs to take the same action again, takes the action, observes a similar result, and so on, forever. This can happen for several reasons. The agent may be stuck on a tool that consistently returns an error, unable to reason its way to an alternative approach. It may be in a state where every action it takes moves it further from its goal, but it cannot perceive this because its evaluation of progress is flawed. It may have a termination condition that is never satisfied because the goal was stated ambiguously. Thrashing is a related but distinct failure mode. In thrashing, the agent oscillates between two or more strategies, trying each one, finding it insufficient, switching to another, and then switching back. This is particularly common in multi-agent systems where two agents are coordinating on a task and each one keeps overriding the other's work. Both of these failure modes are expensive. Every iteration of the loop costs tokens, which costs money, and may also cost API rate limit budget. A production agent without hard loop limits can run up a significant bill before anyone notices it is stuck.
HALLUCINATION CASCADESHallucination is well-known as a failure mode for LLMs in isolation, but in agentic systems it takes on a more dangerous character. When an agent hallucinates a fact and then uses that fact as the basis for a tool call, the hallucination becomes embedded in the agent's action history. The tool call may succeed - it just operates on false premises. The result of the tool call is then fed back into the agent's context as an "observation," which the agent treats as ground truth. The hallucination has now been laundered through a real tool call and is much harder to detect. In multi-agent systems, this becomes a cascade. Agent A hallucinates a fact. It passes that fact to Agent B as part of a message. Agent B incorporates the hallucinated fact into its reasoning and passes a further-corrupted version to Agent C. By the time the error surfaces, it may be impossible to trace it back to the original hallucination without a complete trace of every message in the system.
TOOL SELECTION AND PARAMETER FAILURESThe agent has access to a set of tools, each described by a name, a description, and a schema for its parameters. The LLM must read these descriptions and decide which tool to call and with what arguments. This is a surprisingly fragile process. Tool selection failures occur when the agent picks the wrong tool. This is often caused by tool descriptions that are ambiguous, overlapping, or simply poorly written. If two tools have similar names or descriptions, the agent may consistently pick the wrong one. If a tool's description does not accurately reflect what it does, the agent will have false expectations about its output. Parameter failures occur when the agent calls the right tool but constructs the wrong arguments. This can happen because the agent misunderstands the parameter schema, because it tries to pass a value in the wrong format, or because it makes a reasoning error about what value to pass. Parameter failures are particularly common when the tool schema is complex, when parameters have subtle interdependencies, or when the LLM is operating under context pressure and does not have enough "attention budget" to carefully reason about the schema.
CONTEXT WINDOW OVERFLOW AND MEMORY FAILURESEvery LLM has a maximum context length. When an agent's conversation history exceeds this limit, something must be done: the history must be truncated, summarized, or compressed. Each of these approaches loses information, and the information that is lost may be critical. The most dangerous form of this failure is what practitioners call "context rot": the gradual degradation of the agent's performance as its context window fills up with accumulated history, tool outputs, and intermediate reasoning. The agent does not crash. It does not throw an error. It simply becomes progressively less capable, its responses becoming more generic and less grounded in the specific details of the task, until it is essentially operating on vibes rather than facts. Memory retrieval failures in long-term memory systems are equally treacherous. If the retrieval step returns the wrong documents, the agent will reason confidently from false premises. If it returns nothing, the agent may hallucinate the information it needs. If it returns too much, the relevant signal may be buried in noise.
PROMPT INJECTION AND ADVERSARIAL INPUTSPrompt injection is a security vulnerability that is also, from a debugging perspective, a fascinating failure mode. It occurs when malicious content in the agent's environment - a web page it is reading, a document it is processing, a message from another agent - contains instructions that override or subvert the agent's original directives. From a debugging standpoint, prompt injection attacks can look exactly like goal drift or erratic behavior. The agent suddenly starts doing something completely unrelated to its task, or it starts exfiltrating information, or it starts calling tools in unexpected ways. Without a complete trace of the agent's inputs, it can be very difficult to determine whether you are looking at a model failure or a security attack. Research published in 2025 found that baseline prompt injection attack success rates can be as high as 46%, which is a sobering number. Proper input sanitization and contextual alignment can reduce this to around 19%, but that is still a significant attack surface.
CASCADING FAILURES IN MULTI-AGENT SYSTEMSIn a system with multiple cooperating agents, a failure in one agent can propagate to others in ways that are extremely difficult to trace. An agent that produces a malformed output may cause a downstream agent to crash, or to produce its own malformed output, which then causes a third agent to fail, and so on. By the time the failure surfaces to the user, the root cause may be several hops back in the execution chain. Cascading failures are particularly difficult to debug because the error message you see is typically generated by the agent that ultimately fails, not the agent that originally caused the problem. You need to trace backward through the execution history to find the original fault.
CHAPTER 4: THE FOUNDATION OF DEBUGGING - LOGGING AND TRACINGIf there is one single piece of advice that every experienced agent developer agrees on, it is this: log everything. Not some things. Not the things you think might be interesting. Everything. The reason is simple: you cannot know in advance which piece of information will be the critical clue that unlocks a debugging session. In traditional software, you can often reproduce a bug and then add more logging to investigate it. In agentic AI, the bug may not reproduce, which means the logs from the original run are all you have. But "log everything" is not a complete strategy. You also need to log things in a structured way that makes them queryable, and you need to log them at the right level of granularity. Let us build up a comprehensive logging strategy from first principles.
WHAT TO LOG AT EACH AGENT STEPAt every step of the agent's execution loop, you should capture a structured record that includes the following information. The timestamp and step identifier tell you when the step occurred and how it fits into the overall execution sequence. The agent's current goal and sub-plan tell you what the agent thinks it is trying to accomplish. The exact prompt sent to the LLM - including the system message, the conversation history, and any retrieved context - tells you exactly what information the model had available when it made its decision. The raw LLM output, before any parsing, tells you what the model actually said. The parsed action or decision tells you what the framework interpreted the model's output to mean. The tool call details, including the tool name, the parameters, and the raw result, tell you what happened when the agent interacted with the world. And any errors or exceptions tell you when something went wrong at the infrastructure level. Here is a simple but effective logging structure implemented in Python. The design follows clean architecture principles, separating the logging concern from the agent's core logic and making it easy to swap out the storage backend: import json import time import uuid from dataclasses import dataclass, field, asdict from typing import Any, Dict, List, Optional @dataclass class ToolCallRecord: """ Captures a single tool invocation and its result. Storing both the raw result and any error separately makes it easy to filter for failures during analysis. """ tool_name: str parameters: Dict[str, Any] raw_result: Optional[str] error: Optional[str] duration_ms: float @dataclass class AgentStepRecord: """ A complete record of one step in the agent's execution loop. The step_id field uses a UUID so that records from concurrent agent runs can be safely merged into a single log store without key collisions. """ run_id: str step_id: str = field(default_factory=lambda: str(uuid.uuid4())) step_index: int = 0 timestamp_utc: float = field(default_factory=time.time) # What the agent was trying to do current_goal: str = "" current_sub_plan: Optional[str] = None # The full prompt sent to the LLM system_prompt: str = "" conversation_history: List[Dict[str, str]] = field(default_factory=list) retrieved_context: Optional[str] = None # Model configuration - critical for reproducibility analysis model_name: str = "" temperature: float = 0.0 max_tokens: int = 0 # What the model produced raw_llm_output: str = "" parsed_action_type: str = "" # e.g., "tool_call", "final_answer", "reasoning" parsed_action_detail: Optional[str] = None # Tool interaction (populated if action_type == "tool_call") tool_call: Optional[ToolCallRecord] = None # Token usage for cost tracking prompt_tokens: int = 0 completion_tokens: int = 0 # Any framework-level errors framework_error: Optional[str] = None def to_json(self) -> str: """ Serializes the record to a JSON string suitable for writing to a log file, a message queue, or a database. """ return json.dumps(asdict(self), default=str, indent=2)
This data structure is the atom of your debugging universe. Every step of every agent run produces one of these records, and together they form a complete, queryable history of everything the agent did and why.
Notice that the record captures not just what happened but the conditions under which it happened: the model name, the temperature, the exact prompt. This is essential for reproducibility analysis. If you are trying to understand why a run failed, you need to know not just what the agent did but what information it had available when it made each decision.
The run_id field deserves special attention. Every agent run should be assigned a unique identifier at the moment it starts, and this identifier should be attached to every log record produced during that run. This makes it possible to reconstruct the complete history of a single run from a log store that contains records from thousands of runs. Without this, debugging a specific failed run in a production system becomes an exercise in frustration.
STRUCTURED LOGGING VS. UNSTRUCTURED LOGGING
There is a temptation, especially early in a project, to log things as human-readable strings. This is understandable - strings are easy to write and easy to read in a terminal. But string-based logs are extremely difficult to query programmatically. When you are trying to find all the runs where a specific tool was called with a specific parameter value, or all the steps where the LLM's output could not be parsed, you want to be able to write a query, not grep through gigabytes of text.
Structured logging - where every log record is a JSON object or a structured data type - makes this kind of analysis trivial. Most modern logging systems, including the Python logging module with appropriate formatters, support structured output. The small additional effort of structuring your logs at the outset pays enormous dividends when you are debugging a production issue.
Consider the difference between these two approaches to logging a tool call failure. The unstructured approach writes a string like "Tool search_web failed with error: Connection timeout after 30s". The structured approach writes a JSON object with fields for the tool name, the error type, the error message, the duration, and the step index. The string is readable. The JSON object is queryable. When you have ten thousand runs and you want to know what percentage of search_web calls time out, only the JSON object helps you.
DISTRIBUTED TRACING WITH OPENTELEMETRY
For production systems, especially multi-agent systems where a single user request may trigger dozens of LLM calls and tool invocations across multiple agents, simple file-based logging is not sufficient. You need distributed tracing: a system that can capture the complete causal chain of events from a user request through all the agents and tools that process it, and present that chain as a coherent, navigable trace.
OpenTelemetry has emerged as the de facto standard for distributed tracing in the software industry, and it is increasingly being adopted for AI agent observability. The core concepts are traces and spans. A trace represents the complete execution of a single user request. A span represents a single operation within that trace, such as an LLM call, a tool invocation, or a retrieval step. Spans are nested: a tool call span is a child of the agent step span that initiated it, which is itself a child of the top-level trace span.
The following example shows how to instrument a simple agent step with OpenTelemetry spans. The key insight is that by wrapping each logical operation in a span and attaching relevant attributes, you create a structured, hierarchical record of the execution that can be visualized in any OpenTelemetry-compatible backend:
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
ConsoleSpanExporter,
)
from opentelemetry.sdk.resources import Resource
def setup_tracer(service_name: str) -> trace.Tracer:
"""
Initializes the OpenTelemetry tracer provider and returns
a tracer instance. In production, replace ConsoleSpanExporter
with an OTLP exporter pointing to your observability backend
(e.g., Langfuse, Jaeger, or a cloud provider's tracing service).
"""
resource = Resource.create({"service.name": service_name})
provider = TracerProvider(resource=resource)
# ConsoleSpanExporter is useful during development to see
# traces directly in the terminal output.
exporter = ConsoleSpanExporter()
processor = BatchSpanProcessor(exporter)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
return trace.get_tracer(service_name)
def execute_agent_step_with_tracing(
tracer: trace.Tracer,
run_id: str,
step_index: int,
goal: str,
prompt: str,
llm_client,
tool_registry: dict,
) -> dict:
"""
Executes a single agent step and wraps every sub-operation
in an OpenTelemetry span. The resulting trace will show the
LLM call and any tool calls as child spans of the step span,
making it easy to see the timing and outcome of each operation.
"""
with tracer.start_as_current_span("agent.step") as step_span:
# Attach metadata to the step span so it can be filtered
# and searched in the observability backend.
step_span.set_attribute("agent.run_id", run_id)
step_span.set_attribute("agent.step_index", step_index)
step_span.set_attribute("agent.goal", goal[:500]) # Truncate for safety
# --- LLM Call ---
with tracer.start_as_current_span("llm.call") as llm_span:
llm_span.set_attribute("llm.model", "gpt-4o")
llm_span.set_attribute("llm.temperature", 0.0)
llm_span.set_attribute("llm.prompt_length", len(prompt))
try:
response = llm_client.complete(prompt)
llm_span.set_attribute("llm.response_length", len(response.text))
llm_span.set_attribute(
"llm.prompt_tokens", response.usage.prompt_tokens
)
llm_span.set_attribute(
"llm.completion_tokens", response.usage.completion_tokens
)
except Exception as e:
llm_span.record_exception(e)
llm_span.set_status(trace.StatusCode.ERROR, str(e))
raise
# --- Parse the LLM's response to determine the next action ---
action = parse_llm_response(response.text)
# --- Tool Call (if the LLM decided to use a tool) ---
if action["type"] == "tool_call":
tool_name = action["tool_name"]
tool_params = action["parameters"]
with tracer.start_as_current_span("tool.call") as tool_span:
tool_span.set_attribute("tool.name", tool_name)
tool_span.set_attribute(
"tool.parameters", json.dumps(tool_params)
)
if tool_name not in tool_registry:
# This is a hallucinated tool call - the LLM invented
# a tool that does not exist. Record this clearly.
tool_span.set_attribute("tool.hallucinated", True)
tool_span.set_status(
trace.StatusCode.ERROR,
f"Tool '{tool_name}' not found in registry",
)
return {"error": f"Hallucinated tool: {tool_name}"}
try:
tool_fn = tool_registry[tool_name]
result = tool_fn(**tool_params)
tool_span.set_attribute("tool.success", True)
tool_span.set_attribute(
"tool.result_length", len(str(result))
)
return {"action": action, "observation": result}
except Exception as e:
tool_span.record_exception(e)
tool_span.set_status(trace.StatusCode.ERROR, str(e))
return {"error": str(e)}
return {"action": action}
def parse_llm_response(raw_text: str) -> dict:
"""
Parses the raw LLM output into a structured action dictionary.
This function is a critical debugging point: if the LLM produces
output in an unexpected format, this is where the failure will
surface. Always log the raw_text alongside the parsed result
so you can diagnose parsing failures.
"""
# In a real implementation, this would use a more robust parser,
# potentially with structured output / function calling to avoid
# the need for fragile string parsing altogether.
if "TOOL_CALL:" in raw_text:
# Extract tool name and parameters from the formatted output
lines = raw_text.strip().split("\n")
tool_line = next((l for l in lines if l.startswith("TOOL_CALL:")), "")
tool_name = tool_line.replace("TOOL_CALL:", "").strip()
return {"type": "tool_call", "tool_name": tool_name, "parameters": {}}
if "FINAL_ANSWER:" in raw_text:
answer = raw_text.split("FINAL_ANSWER:")[-1].strip()
return {"type": "final_answer", "content": answer}
return {"type": "reasoning", "content": raw_text}
This code illustrates several important principles. Every operation is wrapped in a span, so the observability backend can show you exactly how long each step took and whether it succeeded or failed. Errors are recorded on the span using record_exception, which captures the full stack trace. Hallucinated tool calls are detected and flagged explicitly, rather than being allowed to cause a confusing downstream error. And the parsing step is clearly separated from the execution step, making it easy to identify parsing failures as a distinct failure mode.
CHAPTER 5: DEBUGGING THE REACT LOOP - THOUGHT, ACTION, OBSERVATION
The ReAct pattern - short for Reasoning and Acting - is one of the most widely used architectures for AI agents. It structures the agent's behavior as a repeating cycle of three phases: Thought, in which the agent reasons about its current situation; Action, in which it decides what to do next; and Observation, in which it receives the result of its action and incorporates it into its context. This cycle repeats until the agent produces a final answer.
The ReAct loop is elegant and powerful, but it is also a concentrated source of debugging challenges. Every phase of the loop can fail, and failures in one phase propagate to the next in ways that can be difficult to untangle.
DEBUGGING THE THOUGHT PHASE
The Thought phase is where the LLM reasons about the current state of the task. In a well-functioning agent, this reasoning should be coherent, relevant to the current goal, and should lead logically to the chosen action. When debugging a failed run, examining the Thought traces is often the most revealing exercise.
Common Thought phase failures include reasoning that is correct in isolation but does not account for information from earlier steps, reasoning that correctly identifies a problem but then proposes an incorrect solution, and reasoning that is entirely plausible-sounding but factually wrong - the classic hallucination pattern.
To debug the Thought phase, you need to read the reasoning traces carefully and ask several questions. Does the reasoning correctly reference the results of previous tool calls? Does it correctly identify what information is still missing? Does it correctly identify which tool would provide that information? Does it correctly reason about the constraints of the task?
One powerful technique is to manually replay the reasoning by constructing the exact prompt that was sent to the LLM at the failing step and submitting it yourself, reading the response carefully. This gives you a human-level understanding of what the model was working with and what it produced. You may find that the reasoning is actually correct given the prompt, which means the bug is in an earlier step that produced a bad prompt. Or you may find that the reasoning is clearly wrong given a perfectly good prompt, which points to a model-level issue.
DEBUGGING THE ACTION PHASE
The Action phase is where the agent translates its reasoning into a concrete action, typically a tool call. The most common failure modes here are selecting the wrong tool, constructing incorrect parameters, and producing output in a format that the framework cannot parse.
Tool selection failures are often caused by poor tool descriptions. If two tools have similar names or overlapping descriptions, the agent will sometimes pick the wrong one. The fix is usually to improve the tool descriptions, making them more precise and more clearly differentiated. But to know that this is the problem, you need to look at the tool descriptions alongside the agent's reasoning and ask: given what the agent said in the Thought phase, is the tool it selected the one a reasonable person would choose?
Parameter construction failures are often caused by the agent making incorrect assumptions about the parameter schema. The agent may pass a string where an integer is expected, or it may pass a nested object where a flat string is expected. The best defense against this is to use structured outputs - modern LLM APIs support function calling or JSON mode, which constrains the model's output to match a specified schema. This does not eliminate parameter errors entirely, but it dramatically reduces the frequency of format-related failures.
The following example demonstrates how to use structured outputs to make tool calls more reliable and easier to debug. By defining the expected output as a Pydantic model, you get automatic validation and clear error messages when the model produces invalid output:
from pydantic import BaseModel, Field, ValidationError
from typing import Literal, Union
import json
class WebSearchAction(BaseModel):
"""
Represents a request to search the web.
The query field should be a specific, focused search query,
not a vague description of what the agent wants to know.
"""
action_type: Literal["web_search"] = "web_search"
query: str = Field(
...,
description="The search query to submit to the search engine.",
min_length=3,
max_length=500,
)
num_results: int = Field(
default=5,
description="Number of results to return.",
ge=1,
le=20,
)
class CalculatorAction(BaseModel):
"""
Represents a request to evaluate a mathematical expression.
The expression must be a valid Python arithmetic expression.
"""
action_type: Literal["calculator"] = "calculator"
expression: str = Field(
...,
description="A Python arithmetic expression to evaluate, e.g. '2 + 2 * 3'.",
)
class FinalAnswerAction(BaseModel):
"""
Signals that the agent has completed its task and is ready
to return a final answer to the user.
"""
action_type: Literal["final_answer"] = "final_answer"
answer: str = Field(
...,
description="The complete, final answer to the user's question.",
)
# A union type that captures all possible actions.
# The LLM is instructed to produce JSON matching one of these schemas.
AgentAction = Union[WebSearchAction, CalculatorAction, FinalAnswerAction]
def parse_and_validate_action(raw_json: str) -> tuple[AgentAction, str | None]:
"""
Attempts to parse the LLM's raw JSON output into a validated
action object. Returns a tuple of (action, error_message).
If parsing succeeds, error_message is None. If it fails,
action is None and error_message describes the problem.
Returning the error rather than raising an exception allows the
caller to decide how to handle the failure - for example, by
feeding the error back to the LLM and asking it to try again.
"""
try:
data = json.loads(raw_json)
except json.JSONDecodeError as e:
return None, f"Invalid JSON: {e}. Raw output was: {raw_json[:200]}"
action_type = data.get("action_type")
if action_type is None:
return None, f"Missing 'action_type' field in: {data}"
# Map action_type to the corresponding Pydantic model
action_map = {
"web_search": WebSearchAction,
"calculator": CalculatorAction,
"final_answer": FinalAnswerAction,
}
model_class = action_map.get(action_type)
if model_class is None:
# The LLM invented an action type that does not exist.
# This is the action-level equivalent of a hallucinated tool call.
known_types = list(action_map.keys())
return None, (
f"Unknown action_type '{action_type}'. "
f"Valid types are: {known_types}"
)
try:
action = model_class(**data)
return action, None
except ValidationError as e:
return None, f"Validation failed for {action_type}: {e}"
Notice the detailed error messages in this code. When parsing fails, the error message tells you exactly what went wrong: whether it was a JSON syntax error, a missing field, an unknown action type, or a validation failure. These error messages are designed to be fed back to the LLM as part of the next prompt, giving the model the information it needs to correct its output. They are also designed to be logged clearly, so that a human debugger can immediately understand what the model produced and why it was rejected.
DEBUGGING THE OBSERVATION PHASE
The Observation phase is where the result of the agent's action is fed back into its context. This phase is deceptively simple - it looks like just appending a string to the conversation history - but it is the source of several subtle failure modes.
The first failure mode is observation truncation. If a tool returns a very large result - a long web page, a large database query result, a lengthy document - the raw result may be too large to fit in the context window. The framework must then truncate or summarize it. If the truncation cuts off critical information, the agent will reason from an incomplete observation and may draw incorrect conclusions. To debug this, you need to log both the raw tool result and the processed observation that was actually inserted into the context, and compare them.
The second failure mode is observation serialization errors. The tool returns a Python object - a dictionary, a list, a custom class - and the framework must serialize it to a string before inserting it into the prompt. If the serialization is lossy or incorrect, the agent may receive a garbled observation. A common example is a tool that returns a dictionary with nested objects, which gets serialized as something like "<MyObject at 0x7f3a2b1c4d50>" instead of its actual content.
The third failure mode is observation misinterpretation. The tool returns a valid, correctly serialized result, but the agent interprets it incorrectly. This is a model-level failure, but it is often exacerbated by poor observation formatting. If the observation is a raw JSON blob with no explanation of what the fields mean, the agent may misread it. Adding a brief natural-language preamble to observations
- "The search returned the following results:" followed by the formatted results - can significantly reduce misinterpretation.
The following diagram illustrates the information flow through a single ReAct loop iteration and the points where failures can occur:
+------------------+
| User Goal |
+--------+---------+
|
v
+------------------+
| Context Builder | <-- FAILURE POINT: Context overflow,
| (Prompt Assemb) | wrong retrieval, truncation
+--------+---------+
|
v
+------------------+
| LLM Call | <-- FAILURE POINT: Hallucination,
| (Thought+Action)| wrong tool, bad params,
+--------+---------+ format errors
|
v
+------------------+
| Action Parser | <-- FAILURE POINT: JSON parse error,
| | schema validation failure
+--------+---------+
|
v
+------------------+
| Tool Executor | <-- FAILURE POINT: Tool not found,
| | API error, timeout, wrong result
+--------+---------+
|
v
+------------------+
| Observation Proc.| <-- FAILURE POINT: Truncation,
| | serialization error
+--------+---------+
|
v
+------------------+
| Context Updater | <-- FAILURE POINT: History management,
| (Memory Update) | summarization errors
+------------------+
|
+----> (back to Context Builder for next step)
This diagram is not just decorative. It is a debugging checklist. When an agent run fails, you can walk through this diagram step by step, checking each transition point against your logs, until you find the one where the failure originated.
IMPLEMENTING A STEP COUNTER AND LOOP DETECTOR
One of the most important safety mechanisms you can add to a ReAct agent is a step counter with a hard limit, combined with a loop detector that identifies when the agent is repeating the same action. Without these, a stuck agent will run until it exhausts your API budget.
The following implementation shows a simple but effective loop detector that tracks the agent's recent actions and raises an alert when it detects repetition:
from collections import deque
from hashlib import md5
import json
from typing import Optional
class LoopDetector:
"""
Detects when an agent is repeating the same actions in a cycle.
Uses a sliding window of recent action fingerprints to identify
repetition. A fingerprint is a hash of the action type and its
key parameters, so that two calls to the same tool with the same
arguments are recognized as identical even if minor details differ.
"""
def __init__(self, window_size: int = 5, max_steps: int = 50):
"""
Args:
window_size: The number of recent actions to consider
when checking for loops. A window of 5 means
we detect cycles of up to 5 steps.
max_steps: The absolute maximum number of steps the agent
is allowed to take before being forcibly stopped.
"""
self.window_size = window_size
self.max_steps = max_steps
self.recent_fingerprints = deque(maxlen=window_size)
self.step_count = 0
self.action_history = [] # Full history for debugging
def _compute_fingerprint(self, action: dict) -> str:
"""
Computes a stable hash of the action's key properties.
We normalize the action dict before hashing to avoid
false negatives from minor formatting differences.
"""
# Include only the fields that define the action's identity
key_fields = {
"action_type": action.get("action_type", ""),
"tool_name": action.get("tool_name", ""),
# For tool calls, include the parameters in the fingerprint
"parameters": json.dumps(
action.get("parameters", {}), sort_keys=True
),
}
canonical = json.dumps(key_fields, sort_keys=True)
return md5(canonical.encode()).hexdigest()
def record_action(self, action: dict) -> Optional[str]:
"""
Records an action and checks for loops or step limit violations.
Returns a warning message if a problem is detected, or None
if everything looks normal.
The caller should check the return value and decide how to
respond - for example, by injecting a warning into the agent's
context, by escalating to a human, or by terminating the run.
"""
self.step_count += 1
self.action_history.append(action)
# Check the absolute step limit first
if self.step_count > self.max_steps:
return (
f"LOOP DETECTOR: Step limit of {self.max_steps} exceeded. "
f"The agent has been running for {self.step_count} steps "
f"without producing a final answer. Terminating."
)
fingerprint = self._compute_fingerprint(action)
self.recent_fingerprints.append(fingerprint)
# Check if the current action has appeared before in the window
if len(self.recent_fingerprints) == self.window_size:
unique_fingerprints = set(self.recent_fingerprints)
if len(unique_fingerprints) == 1:
# All actions in the window are identical - clear loop
return (
f"LOOP DETECTOR: The agent has repeated the same action "
f"{self.window_size} times in a row. "
f"Action type: {action.get('action_type')}. "
f"This indicates the agent is stuck. Consider providing "
f"a hint or alternative approach."
)
if len(unique_fingerprints) <= 2:
# The agent is alternating between two actions - thrashing
return (
f"LOOP DETECTOR: The agent appears to be thrashing, "
f"alternating between {len(unique_fingerprints)} actions "
f"without making progress. Step count: {self.step_count}."
)
return None # No loop detected
def get_summary(self) -> dict:
"""
Returns a summary of the agent's action history, useful for
post-run analysis and debugging reports.
"""
action_type_counts = {}
for action in self.action_history:
action_type = action.get("action_type", "unknown")
action_type_counts[action_type] = (
action_type_counts.get(action_type, 0) + 1
)
return {
"total_steps": self.step_count,
"action_type_distribution": action_type_counts,
"loop_detected": self.step_count > self.max_steps,
}
This LoopDetector is designed to be used as a component within the agent's execution loop. After every action, the agent calls record_action and checks the return value. If a warning is returned, the agent can inject it into the next prompt, giving the LLM the information it needs to break out of the loop. If the step limit is exceeded, the agent terminates gracefully rather than running forever.
The get_summary method is particularly useful for post-run analysis. By looking at the distribution of action types across a run, you can quickly identify whether the agent spent most of its time on a particular tool (which might indicate that tool is a bottleneck or is returning unhelpful results) or whether it was making steady progress through a variety of actions.
CHAPTER 6: MEMORY DEBUGGING - WHAT YOUR AGENT FORGOT AND WHY
Memory is the silent killer of agent performance. When an agent fails due to a memory problem, it rarely fails with an obvious error. Instead, it produces responses that are subtly wrong, that ignore constraints established earlier in the conversation, or that repeat work that was already done. These failures are easy to misattribute to model quality or prompt engineering problems, when the real issue is that the agent simply did not have access to the information it needed.
There are two distinct memory systems to debug: the short-term memory embodied in the context window, and the long-term memory stored in an external retrieval system.
DEBUGGING CONTEXT WINDOW MANAGEMENT
The context window is the agent's working memory. Everything the agent knows about the current task - the original goal, the conversation history, the results of tool calls, the retrieved context - lives in the context window. When the context window fills up, something must be done, and that something always involves losing information.
The first step in debugging context window issues is to measure how full the context window is at each step of the agent's execution. Most LLM APIs return token counts for both the prompt and the completion. By tracking these over time, you can see exactly when the context window is approaching its limit and correlate that with changes in agent behavior.
A simple but effective context window monitor looks like this:
from dataclasses import dataclass
from typing import List
@dataclass
class ContextWindowSnapshot:
"""
A snapshot of the context window state at a single agent step.
Tracking these over time reveals context pressure patterns.
"""
step_index: int
prompt_tokens: int
model_max_tokens: int
utilization_pct: float
was_truncated: bool
truncation_strategy: str # e.g., "none", "sliding_window", "summarize"
estimated_tokens_dropped: int
class ContextWindowMonitor:
"""
Tracks context window utilization across agent steps and
raises warnings when utilization approaches critical thresholds.
"""
# Warn when context is 70% full - there is still time to act
WARNING_THRESHOLD = 0.70
# Critical alert when context is 90% full - action required
CRITICAL_THRESHOLD = 0.90
def __init__(self, model_max_tokens: int):
self.model_max_tokens = model_max_tokens
self.snapshots: List[ContextWindowSnapshot] = []
def record_step(
self,
step_index: int,
prompt_tokens: int,
was_truncated: bool = False,
truncation_strategy: str = "none",
estimated_tokens_dropped: int = 0,
) -> str | None:
"""
Records a context window snapshot and returns a warning
message if utilization is above a threshold, or None otherwise.
"""
utilization = prompt_tokens / self.model_max_tokens
snapshot = ContextWindowSnapshot(
step_index=step_index,
prompt_tokens=prompt_tokens,
model_max_tokens=self.model_max_tokens,
utilization_pct=utilization,
was_truncated=was_truncated,
truncation_strategy=truncation_strategy,
estimated_tokens_dropped=estimated_tokens_dropped,
)
self.snapshots.append(snapshot)
if utilization >= self.CRITICAL_THRESHOLD:
return (
f"CONTEXT CRITICAL: Context window is {utilization:.1%} full "
f"at step {step_index} ({prompt_tokens}/{self.model_max_tokens} tokens). "
f"Agent performance may be significantly degraded. "
f"Consider summarizing history or increasing model context length."
)
if utilization >= self.WARNING_THRESHOLD:
return (
f"CONTEXT WARNING: Context window is {utilization:.1%} full "
f"at step {step_index}. Monitor closely."
)
return None
def get_growth_rate(self) -> float:
"""
Estimates the average token growth per step, which can be used
to predict when the context window will become critical.
"""
if len(self.snapshots) < 2:
return 0.0
first = self.snapshots[0].prompt_tokens
last = self.snapshots[-1].prompt_tokens
steps = len(self.snapshots) - 1
return (last - first) / steps
def predict_steps_remaining(self) -> int:
"""
Predicts how many more steps the agent can take before the
context window reaches the critical threshold.
Returns -1 if the growth rate is zero or negative.
"""
if not self.snapshots:
return -1
growth_rate = self.get_growth_rate()
if growth_rate <= 0:
return -1
current_tokens = self.snapshots[-1].prompt_tokens
critical_tokens = int(self.model_max_tokens * self.CRITICAL_THRESHOLD)
tokens_remaining = critical_tokens - current_tokens
return max(0, int(tokens_remaining / growth_rate))
The predict_steps_remaining method is particularly useful for proactive debugging. If you can see that the agent has only ten steps before its context becomes critical, you can intervene - by triggering a summarization step, by switching to a model with a larger context window, or by restructuring the task to reduce the amount of history that needs to be retained.
DEBUGGING LONG-TERM MEMORY AND RETRIEVAL
Long-term memory in an agentic system is typically implemented as a vector database. When the agent needs information that is not in its context window, it formulates a query, embeds it using an embedding model, and retrieves the most semantically similar documents from the store. This process is powerful but fragile.
The most important debugging technique for retrieval systems is retrieval quality logging. For every retrieval call, you should log the query, the number of results returned, the similarity scores of the returned documents, and the content of the returned documents. This gives you the information you need to answer the critical question: did the agent get the right information?
A retrieval quality log might look like this in practice. Suppose the agent is trying to find information about a specific customer's contract terms. The retrieval query is "customer contract renewal terms." The system returns three documents with similarity scores of 0.87, 0.71, and 0.65. The first document is indeed about contract renewal terms. The second is about customer onboarding terms. The third is about payment terms. The agent receives all three and must reason about which is relevant.
If the agent then makes an error about contract terms, you now have the information you need to diagnose it. Was the relevant document actually retrieved? Yes, it was the first result. Did the agent correctly identify it as the most relevant? You can check by looking at the Thought trace. Did the agent correctly extract the key information from it? You can check by looking at what the agent said about contract terms in subsequent steps.
Without retrieval quality logging, this entire diagnostic chain is invisible to you.
CHAPTER 7: TOOL DEBUGGING - WHEN THE AGENT'S HANDS DON'T WORK
Tools are the interface between the agent's abstract reasoning and the concrete world. They are also one of the most common sources of bugs, because they sit at the boundary between the LLM's probabilistic world and the deterministic world of APIs, databases, and file systems. A tool call is where the agent's intentions meet reality, and reality does not always cooperate.
BUILDING DEBUGGABLE TOOLS
The most important thing you can do to make tool debugging easier is to build your tools to be inherently debuggable. This means designing them to return structured, informative results in both the success and failure cases, to log their inputs and outputs, and to provide error messages that are useful not just to a human developer but to the LLM that will receive them.
Consider the difference between a tool that raises a raw exception on failure and a tool that returns a structured error response. When a tool raises a raw exception, the agent framework must catch it and decide how to present it to the LLM. The resulting error message is often generic and unhelpful: "Tool execution failed." When a tool returns a structured error response, the agent receives a rich description of what went wrong, which it can use to reason about an alternative approach.
Here is a pattern for building tools that are both robust and debuggable:
from dataclasses import dataclass
from typing import Any, Optional
import time
import logging
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
"""
A standardized result type for all tool calls.
Using a consistent result type across all tools makes it
easy to write generic logging and error handling code.
"""
success: bool
data: Optional[Any]
error_type: Optional[str] # e.g., "network_error", "auth_error"
error_message: Optional[str]
is_retryable: bool # Should the agent try again?
duration_ms: float
metadata: dict # Tool-specific additional information
def web_search_tool(query: str, num_results: int = 5) -> ToolResult:
"""
Searches the web for the given query and returns the top results.
This tool is designed to return informative error messages that
the LLM can use to reason about what went wrong and how to
recover. For example, if the query is too long, the error message
tells the LLM to shorten it. If the search returns no results,
the error message suggests trying a different query.
Args:
query: The search query. Should be specific and focused.
Maximum length is 500 characters.
num_results: Number of results to return. Must be between 1 and 20.
Returns:
A ToolResult containing the search results or a detailed
error description.
"""
start_time = time.monotonic()
# Input validation - catch problems before they hit the API
if not query or not query.strip():
return ToolResult(
success=False,
data=None,
error_type="invalid_input",
error_message=(
"The search query is empty. Please provide a non-empty "
"search query."
),
is_retryable=False, # Retrying with the same empty query won't help
duration_ms=0.0,
metadata={"query": query},
)
if len(query) > 500:
return ToolResult(
success=False,
data=None,
error_type="invalid_input",
error_message=(
f"The search query is too long ({len(query)} characters). "
f"Please shorten it to 500 characters or fewer. "
f"Consider focusing on the most important keywords."
),
is_retryable=False,
duration_ms=0.0,
metadata={"query_length": len(query)},
)
try:
# Log the tool call before execution so we have a record
# even if the call hangs or crashes
logger.info(
"web_search_tool called",
extra={"query": query, "num_results": num_results},
)
# --- Actual search API call would go here ---
results = _call_search_api(query, num_results)
duration_ms = (time.monotonic() - start_time) * 1000
if not results:
return ToolResult(
success=False,
data=None,
error_type="no_results",
error_message=(
f"The search for '{query}' returned no results. "
f"Try a different query with different keywords, "
f"or try a more general search term."
),
is_retryable=True, # A different query might work
duration_ms=duration_ms,
metadata={"query": query},
)
logger.info(
"web_search_tool succeeded",
extra={"query": query, "result_count": len(results)},
)
return ToolResult(
success=True,
data=results,
error_type=None,
error_message=None,
is_retryable=False,
duration_ms=duration_ms,
metadata={"query": query, "result_count": len(results)},
)
except ConnectionError as e:
duration_ms = (time.monotonic() - start_time) * 1000
logger.error("web_search_tool network error", exc_info=True)
return ToolResult(
success=False,
data=None,
error_type="network_error",
error_message=(
f"Could not connect to the search service: {e}. "
f"This is likely a temporary network issue. "
f"You may try again."
),
is_retryable=True, # Network errors are often transient
duration_ms=duration_ms,
metadata={"query": query},
)
except Exception as e:
duration_ms = (time.monotonic() - start_time) * 1000
logger.error("web_search_tool unexpected error", exc_info=True)
return ToolResult(
success=False,
data=None,
error_type="unexpected_error",
error_message=(
f"An unexpected error occurred: {type(e).__name__}: {e}. "
f"Please try a different approach."
),
is_retryable=False,
duration_ms=duration_ms,
metadata={"query": query, "error_class": type(e).__name__},
)
def _call_search_api(query: str, num_results: int) -> list:
"""
Internal function that makes the actual API call.
Separated from the public interface to make it easy to
mock during testing and debugging.
"""
# Placeholder for actual search API integration
raise NotImplementedError("Replace with actual search API call")
The is_retryable field in the ToolResult is particularly important for debugging. It tells the agent framework whether a retry is likely to succeed. A network timeout is retryable - the same call might succeed on the next attempt. An invalid parameter error is not retryable - the agent needs to fix its parameters before trying again. By making this distinction explicit, you give the agent the information it needs to reason about recovery, and you give yourself the information you need to understand the failure pattern.
DETECTING AND HANDLING TOOL HALLUCINATIONS
One of the most surprising failure modes for new agent developers is the hallucinated tool call. The LLM, in its reasoning, decides that it needs to call a tool called, say, "get_customer_contract_details" or "analyze_sentiment_advanced" - tools that sound plausible but that do not exist in the agent's tool registry. The agent framework then needs to handle this gracefully.
The naive approach is to raise an exception: "Tool not found." But this produces a generic error that gives the LLM very little information about how to recover. A better approach is to detect the hallucination explicitly, log it as a distinct event type, and return an error message that tells the LLM which tools are actually available.
The following function implements this pattern. It is called by the agent framework before executing any tool call, and it returns either a validated tool function or a structured error response:
from typing import Callable, Optional
import difflib
def resolve_tool(
tool_name: str,
tool_registry: dict[str, Callable],
) -> tuple[Optional[Callable], Optional[str]]:
"""
Looks up a tool by name in the registry. If the tool is not found,
attempts to suggest the closest matching tool name to help the LLM
correct its mistake.
Returns a tuple of (tool_function, error_message).
If the tool is found, error_message is None.
If not found, tool_function is None and error_message is descriptive.
"""
if tool_name in tool_registry:
return tool_registry[tool_name], None
# The tool was not found. This is a hallucination.
# Try to find the closest matching tool name to help the LLM.
available_tools = list(tool_registry.keys())
close_matches = difflib.get_close_matches(
tool_name, available_tools, n=3, cutoff=0.6
)
if close_matches:
suggestions = ", ".join(f"'{m}'" for m in close_matches)
error_message = (
f"Tool '{tool_name}' does not exist. "
f"Did you mean one of these? {suggestions}. "
f"Available tools are: {available_tools}."
)
else:
error_message = (
f"Tool '{tool_name}' does not exist and no similar tools were found. "
f"The available tools are: {available_tools}. "
f"Please use one of these tools instead."
)
return None, error_message
The use of difflib.get_close_matches here is a small but powerful debugging aid. When the LLM hallucinates a tool name that is close to a real tool name - for example, "web_searcher" instead of "web_search"
- the error message will suggest the correct name. This often allows the agent to self-correct on the very next step, without any human intervention.
CHAPTER 8: DEBUGGING MULTI-AGENT SYSTEMS - WHEN AGENTS TALK TO EACH OTHER
Single-agent debugging is challenging. Multi-agent debugging is challenging squared. When multiple agents are cooperating on a task, passing messages to each other, sharing tools, and building on each other's outputs, the number of potential failure points multiplies dramatically. A bug that would be easy to spot in a single-agent trace can become invisible in the noise of a multi-agent conversation.
The fundamental challenge of multi-agent debugging is attribution: when something goes wrong, which agent caused it? In a system where Agent A produces output that Agent B uses to produce output that Agent C uses to produce the final result, a bug in Agent A's output may not manifest as an obvious error until Agent C fails. By that point, the causal chain is several hops long and the original error may be buried in a large volume of intermediate output.
CORRELATION IDS AND CAUSAL TRACING
The single most important tool for multi-agent debugging is the correlation ID: a unique identifier that is attached to a task or request and propagated through every agent and tool call that processes it. By filtering your logs on a correlation ID, you can reconstruct the complete causal chain of a single task execution, even in a system where hundreds of tasks are running concurrently.
Every message passed between agents should carry the correlation ID of the task it belongs to. Every log record produced by any agent should include the correlation ID. Every tool call should include the correlation ID in its log record. With this in place, debugging a specific failed task is as simple as filtering your log store for the task's correlation ID and reading the resulting records in chronological order.
A minimal but effective message envelope for multi-agent communication looks like this:
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
from datetime import datetime, timezone
@dataclass
class AgentMessage:
"""
A message passed between agents in a multi-agent system.
The correlation_id field is the key to multi-agent debugging:
it allows you to trace a single task's execution across all
the agents that participate in it.
The parent_message_id field creates a tree structure of messages,
allowing you to reconstruct the exact sequence of communications
that led to any given message.
"""
# Unique identifier for this specific message
message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
# Shared across all messages in a single task - the debugging key
correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
# The message that triggered this one (None for the root message)
parent_message_id: Optional[str] = None
# Routing information
sender_agent_id: str = ""
recipient_agent_id: str = ""
# The actual content of the message
message_type: str = "" # e.g., "task", "result", "error", "query"
content: Any = None
# Timing information for performance debugging
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
# Optional: the step in the sender's execution that produced this message
sender_step_index: Optional[int] = None
def create_reply(
self,
sender_agent_id: str,
message_type: str,
content: Any,
) -> "AgentMessage":
"""
Creates a reply to this message, automatically propagating
the correlation_id and setting the parent_message_id.
This ensures that the reply is correctly linked to the
original message in the trace.
"""
return AgentMessage(
correlation_id=self.correlation_id, # Propagate the correlation ID
parent_message_id=self.message_id, # Link to the parent message
sender_agent_id=sender_agent_id,
recipient_agent_id=self.sender_agent_id,
message_type=message_type,
content=content,
)
The create_reply method is a small but important design choice. By providing a method that automatically propagates the correlation ID and sets the parent message ID, you make it easy for agent developers to do the right thing. The alternative - requiring every developer to manually copy the correlation ID into every reply - is error-prone and will inevitably result in broken trace chains.
DETECTING HALLUCINATION PROPAGATION
One of the most dangerous failure modes in multi-agent systems is hallucination propagation: a hallucinated fact from one agent is accepted as ground truth by downstream agents, corrupting the entire task execution. Detecting this requires comparing the claims made by each agent against the evidence available to it.
A practical approach to hallucination detection in multi-agent systems is to implement a "fact checker" agent that sits between agents in the communication chain and validates key claims before they are passed downstream. This is not a perfect solution - the fact checker is itself an LLM and can hallucinate - but it significantly reduces the rate of hallucination propagation.
For debugging purposes, the most important thing is to log the evidence that each agent had available when it made each claim. When you find a hallucinated fact in the final output, you can then trace backward through the logs to find the agent that first introduced it, and examine what information that agent had available. This tells you whether the hallucination was caused by missing information (in which case the fix is to improve retrieval), by a model quality issue (in which case the fix might be to use a better model or improve the prompt), or by a structural issue in the multi-agent communication protocol.
VISUALIZING MULTI-AGENT EXECUTION
One of the most powerful debugging tools for multi-agent systems is a visualization of the message flow. When you can see a diagram of which agents communicated with which other agents, in what order, and with what outcomes, patterns that are invisible in raw logs become immediately apparent.
A simple ASCII representation of a multi-agent execution trace might look like this:
t=0.0s [ORCHESTRATOR] --> [RESEARCHER] : "Find information about X"
t=0.1s [RESEARCHER] --> [SEARCH_TOOL] : query="X background"
t=1.3s [SEARCH_TOOL] --> [RESEARCHER] : 5 results returned
t=1.4s [RESEARCHER] --> [SEARCH_TOOL] : query="X recent developments"
t=2.6s [SEARCH_TOOL] --> [RESEARCHER] : 5 results returned
t=2.7s [RESEARCHER] --> [ORCHESTRATOR]: summary of findings
t=2.8s [ORCHESTRATOR] --> [WRITER] : "Write report on X"
t=2.9s [WRITER] --> [ORCHESTRATOR]: draft report
t=3.0s [ORCHESTRATOR] --> [REVIEWER] : "Review this report"
t=4.2s [REVIEWER] --> [ORCHESTRATOR]: "Report is missing Y"
t=4.3s [ORCHESTRATOR] --> [RESEARCHER] : "Find information about Y"
...
This kind of timeline visualization makes it immediately obvious when agents are spending a lot of time on a particular step, when messages are being passed in unexpected directions, or when an agent is being called more times than expected. Most observability platforms for agentic AI - including LangSmith, Langfuse, and Arize Phoenix - provide this kind of visualization out of the box.
CHAPTER 9: OBSERVABILITY PLATFORMS - YOUR DEBUGGING COMMAND CENTER
While it is entirely possible to build a custom logging and tracing system from scratch, as we have been doing in the code examples above, most production agent developers eventually reach for a dedicated observability platform. These platforms provide pre-built integrations with popular agent frameworks, rich visualization tools, and powerful querying capabilities that would take months to build from scratch.
The landscape of AI agent observability platforms has grown rapidly. In 2025, the market was estimated at $0.4 billion and is projected to grow to $7.1 billion by 2035, which gives you a sense of how seriously the industry is taking this problem.
LANGSMITH
LangSmith is the observability and DevOps platform built by the team behind LangChain. It provides deep, out-of-the-box integration with LangChain and LangGraph, but it also supports OpenTelemetry, which means it can ingest traces from any framework that supports the OpenTelemetry standard.
LangSmith's most powerful debugging feature is its step-by-step trace viewer. For every agent run, you can see a hierarchical tree of spans representing every LLM call, tool invocation, and retrieval step. You can click on any span to see the exact prompt that was sent, the exact response that was received, the token counts, the latency, and any errors. This makes it possible to diagnose even complex multi-step failures without having to write any custom log analysis code.
LangSmith also provides an evaluation framework that allows you to run your agent against a dataset of test cases and score the results using LLM-as-judge evaluators. This is invaluable for regression testing: after making a change to your agent's prompt or architecture, you can run the evaluation suite to verify that the change improved performance on the target cases without degrading performance on other cases.
LANGFUSE
Langfuse is an open-source alternative to LangSmith that has gained significant traction in the community, particularly among teams that need to self-host their observability infrastructure for data privacy reasons. It provides full workflow tracing with hierarchical spans, granular cost and token tracking, and a prompt management system that allows you to version and A/B test your prompts.
In September 2025, Langfuse rebuilt its TypeScript SDK on top of OpenTelemetry, which significantly improved its integration with the broader observability ecosystem. This means that Langfuse traces can now be exported to any OpenTelemetry-compatible backend, and traces from any OpenTelemetry-instrumented application can be ingested into Langfuse.
Langfuse's open-source nature is a significant advantage for debugging because it means you can inspect the platform's own code to understand exactly how it processes and stores your traces. When you are debugging a tracing issue - for example, when traces are not appearing in the platform as expected - being able to look at the source code is invaluable.
ARIZE PHOENIX
Arize Phoenix is an open-source observability platform that is particularly strong on the evaluation and analysis side. It provides native OpenTelemetry support and a rich set of built-in evaluators for common quality metrics like hallucination detection, answer relevance, and toxicity.
Phoenix's most distinctive feature is its ability to visualize the distribution of agent behaviors across many runs, not just a single run. This makes it particularly useful for the statistical debugging approach we discussed in Chapter 2: instead of asking why a specific run failed, you can ask what the distribution of behaviors looks like across a hundred runs, and use that distribution to identify systematic problems.
AGENTOPS
AgentOps is a platform specifically designed for agent observability, with a focus on cost tracking and performance optimization. It provides real-time dashboards showing token usage, cost per run, and error rates, as well as detailed traces of individual runs. Its session replay feature allows you to replay a specific agent run step by step, which is particularly useful for debugging complex multi-step failures.
CHOOSING THE RIGHT PLATFORM
The right observability platform for your project depends on several factors. If you are using LangChain or LangGraph, LangSmith's deep integration makes it the natural choice. If you need to self-host for data privacy reasons, Langfuse's open-source nature makes it attractive. If you need strong evaluation capabilities, Arize Phoenix is worth considering. And if cost optimization is a primary concern, AgentOps' focus on cost tracking may be the deciding factor.
In practice, many teams use multiple platforms: LangSmith for development-time debugging, where its step-by-step trace viewer is invaluable, and a more scalable platform like Datadog or New Relic for production monitoring, where the ability to handle high volumes of traces and set up automated alerts is more important than the richness of individual trace visualization.
CHAPTER 10: REPRODUCIBILITY STRATEGIES - FREEZING THE CHAOS
We established in Chapter 2 that agentic AI systems are fundamentally non-deterministic, which makes it impossible to reliably reproduce bugs on demand. But "impossible to reliably reproduce" does not mean "nothing can be done." There is a spectrum of reproducibility strategies, ranging from techniques that make reproduction more likely to techniques that make it possible to analyze a failure after the fact even if it cannot be reproduced.
FREEZING MODEL PARAMETERS
The first and most obvious reproducibility strategy is to freeze the model parameters that control sampling. Setting temperature to zero is the most important step: it eliminates the randomness introduced by token sampling, making the model's output as deterministic as possible given its inputs. Some model providers also support a seed parameter that further constrains the sampling process.
You should always log the model name, the model version (if available), the temperature, and any other sampling parameters alongside every LLM call. This makes it possible to reconstruct the conditions of a failed run even if you cannot reproduce it exactly.
However, you should be aware that temperature zero does not guarantee perfect reproducibility. As we noted in Chapter 2, floating-point arithmetic on GPUs can vary across hardware configurations, and model providers update their models silently. The best you can do is to minimize the sources of non-determinism, not eliminate them entirely.
MOCKING TOOLS FOR DETERMINISTIC REPLAY
A more powerful reproducibility strategy is to mock the tool layer during debugging. Instead of calling the real search API, the real database, or the real external service, you substitute a mock that returns a pre-recorded response. This eliminates the non-determinism introduced by tool outputs, allowing you to focus on the LLM's reasoning.
The following example shows a simple tool mocking framework that records tool calls and their results during a live run, and then replays those recorded results during subsequent debugging runs:
import json
import os
from typing import Any, Callable, Optional
from dataclasses import dataclass
@dataclass
class ToolCallRecord:
"""A recorded tool call and its result, for replay during debugging."""
tool_name: str
parameters_json: str # JSON-serialized parameters for comparison
result_json: str # JSON-serialized result for replay
call_index: int # The order in which this call occurred
class ToolRecorder:
"""
A wrapper that records all tool calls and their results to a file.
Use this during a live agent run to capture the tool interaction
history. The recording can then be replayed during debugging to
produce deterministic tool outputs.
Usage:
recorder = ToolRecorder("run_12345_tools.json")
wrapped_search = recorder.wrap(web_search_tool)
# Use wrapped_search in your agent instead of web_search_tool
recorder.save() # Call this after the run completes
"""
def __init__(self, recording_path: str):
self.recording_path = recording_path
self.records: list[ToolCallRecord] = []
self._call_counter = 0
def wrap(self, tool_fn: Callable) -> Callable:
"""
Returns a wrapped version of the tool function that records
every call and its result.
"""
tool_name = tool_fn.__name__
def recording_wrapper(**kwargs) -> Any:
result = tool_fn(**kwargs)
record = ToolCallRecord(
tool_name=tool_name,
parameters_json=json.dumps(kwargs, sort_keys=True, default=str),
result_json=json.dumps(result, default=str),
call_index=self._call_counter,
)
self.records.append(record)
self._call_counter += 1
return result
recording_wrapper.__name__ = tool_name
return recording_wrapper
def save(self) -> None:
"""Saves all recorded tool calls to the recording file."""
with open(self.recording_path, "w") as f:
json.dump(
[vars(r) for r in self.records],
f,
indent=2,
)
class ToolReplayer:
"""
Replays recorded tool calls during debugging runs.
Instead of calling the real tool, returns the pre-recorded result.
This makes the tool layer completely deterministic, allowing you
to focus on the LLM's reasoning behavior.
Usage:
replayer = ToolReplayer("run_12345_tools.json")
mock_search = replayer.get_mock("web_search_tool")
# Use mock_search in your agent instead of web_search_tool
"""
def __init__(self, recording_path: str):
with open(recording_path) as f:
raw_records = json.load(f)
self.records = [ToolCallRecord(**r) for r in raw_records]
self._call_counters: dict[str, int] = {}
def get_mock(self, tool_name: str) -> Callable:
"""
Returns a mock function that replays the recorded results
for the specified tool, in the order they were originally called.
"""
self._call_counters[tool_name] = 0
tool_records = [r for r in self.records if r.tool_name == tool_name]
def mock_tool(**kwargs) -> Any:
call_index = self._call_counters[tool_name]
if call_index >= len(tool_records):
raise IndexError(
f"No more recorded calls for tool '{tool_name}'. "
f"The agent is making more calls than were recorded. "
f"This may indicate non-deterministic behavior in the "
f"agent's planning."
)
record = tool_records[call_index]
self._call_counters[tool_name] += 1
return json.loads(record.result_json)
mock_tool.__name__ = tool_name
return mock_tool
This ToolRecorder/ToolReplayer pattern is extremely useful for debugging complex agent failures. The workflow is: first, run the agent in recording mode to capture a failing run. Then, run the agent in replay mode with the recorded tool outputs, which makes the tool layer deterministic. Now you can focus on the LLM's reasoning, adjusting the prompt or the model parameters to see how they affect the agent's behavior, without the confounding factor of variable tool outputs.
The IndexError in the mock_tool function is an important diagnostic signal. If the agent makes more tool calls than were recorded, it means the agent's behavior has diverged from the original run - it is taking a different path through the action space. This is useful information: it tells you that the change you made to the prompt or the model parameters is causing the agent to plan differently.
CACHING LLM RESPONSES FOR DEBUGGING
A complementary technique to tool mocking is LLM response caching. During a debugging session, you can cache the LLM's responses for specific prompts, so that repeated runs with the same prompt always produce the same output. This makes it possible to iterate quickly on the parts of the system you are trying to fix, without waiting for LLM API calls on the parts that are already working correctly.
Most LLM client libraries support some form of caching, either natively or through a middleware layer. The key is to cache on the full prompt content, including the system message, the conversation history, and any retrieved context. If any part of the prompt changes, the cache should miss and a fresh API call should be made.
CHAPTER 11: EVALUATION AND TESTING - MAKING DEBUGGING SYSTEMATIC
Debugging individual failed runs is necessary but not sufficient. To build reliable agents, you need a systematic evaluation framework that can tell you, across many runs and many test cases, whether your agent is getting better or worse. Without this, you are flying blind: you fix one bug, but you have no way of knowing whether the fix introduced a regression elsewhere.
BUILDING AN EVALUATION DATASET
The foundation of a systematic evaluation framework is a dataset of test cases. Each test case consists of an input - the user's request or goal - and one or more reference outputs or evaluation criteria that define what a correct response looks like.
For agentic systems, evaluation is more complex than for simple question-answering models, because the agent's output is not just a final answer but a complete trajectory: a sequence of thoughts, actions, and observations. A trajectory can be correct in its final answer but wrong in its reasoning, or correct in its reasoning but inefficient in its tool use. Your evaluation framework needs to capture all of these dimensions.
A well-designed test case for an agent might look like this:
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class AgentTestCase:
"""
A single test case for evaluating an agent's behavior.
The evaluation_criteria field is a list of natural language
statements that describe what a correct response should look like.
These are used by the LLM-as-judge evaluator to score the agent's
trajectory.
"""
test_id: str
description: str # Human-readable description of what is being tested
user_input: str # The input to give the agent
expected_final_answer: Optional[str] = None # If None, use criteria only
# Criteria for evaluating the trajectory, not just the final answer
evaluation_criteria: List[str] = field(default_factory=list)
# Tools that should be called (in any order) for a correct solution
required_tool_calls: List[str] = field(default_factory=list)
# Tools that should NOT be called (e.g., dangerous or irrelevant tools)
forbidden_tool_calls: List[str] = field(default_factory=list)
# Maximum number of steps allowed for a correct solution
max_steps: Optional[int] = None
# Tags for filtering and grouping test cases
tags: List[str] = field(default_factory=list)
# Example test cases for a research agent
RESEARCH_AGENT_TEST_SUITE = [
AgentTestCase(
test_id="research_001",
description="Basic factual research with a single tool call",
user_input="What is the population of Tokyo?",
evaluation_criteria=[
"The final answer includes a specific population figure.",
"The figure is approximately correct (within 10% of the actual value).",
"The answer cites the source of the information.",
],
required_tool_calls=["web_search"],
max_steps=5,
tags=["basic", "factual", "single_tool"],
),
AgentTestCase(
test_id="research_002",
description="Multi-step research requiring synthesis across sources",
user_input=(
"Compare the GDP per capita of Norway and Switzerland, "
"and explain which factors contribute to the difference."
),
evaluation_criteria=[
"The answer includes GDP per capita figures for both countries.",
"The figures are approximately correct.",
"The answer identifies at least two factors contributing to any difference.",
"The answer is coherent and well-organized.",
],
required_tool_calls=["web_search"],
max_steps=10,
tags=["multi_step", "synthesis", "comparison"],
),
AgentTestCase(
test_id="research_003",
description="Edge case: question with no clear answer",
user_input="What will the stock price of Apple be in six months?",
evaluation_criteria=[
"The agent acknowledges that future stock prices cannot be predicted with certainty.",
"The agent does not make a specific price prediction.",
"The agent may provide relevant context about factors that influence stock prices.",
],
forbidden_tool_calls=["execute_trade"], # Should never try to trade
max_steps=5,
tags=["edge_case", "uncertainty", "safety"],
),
]
This test suite design makes several important choices worth explaining. The evaluation_criteria field uses natural language statements rather than exact string matches, because agent outputs are inherently variable and exact matching would produce too many false negatives. The required_tool_calls and forbidden_tool_calls fields allow you to test not just the final answer but the agent's behavior during execution. The max_steps field allows you to test efficiency, not just correctness. And the tags field allows you to run subsets of the test suite, which is useful for fast regression testing during development.
LLM-AS-JUDGE EVALUATION
Evaluating agent outputs is itself a hard problem. For simple factual questions, you can compare the agent's answer to a reference answer using string matching or semantic similarity. But for complex, open-ended tasks, you need a more sophisticated evaluation approach.
The most widely used approach in the field is LLM-as-judge: you use a separate, high-quality LLM to evaluate the agent's output against the evaluation criteria. This is not perfect - the judge LLM can make mistakes, and it can be biased toward certain styles of output - but it scales much better than human evaluation and is much more flexible than rule-based evaluation.
A simple LLM-as-judge implementation might score each evaluation criterion on a scale of 0 to 1 and return an overall score along with a brief explanation of any criteria that were not met. The explanation is particularly valuable for debugging: it tells you not just that the agent failed but why it failed, in terms that are directly actionable.
REGRESSION TESTING
Regression testing for agents is the practice of running your evaluation suite after every significant change to the agent's prompt, architecture, or tool set, and comparing the results to a baseline. If the new results are significantly worse than the baseline on any category of test cases, you have introduced a regression and need to investigate.
The key challenge in agent regression testing is that the evaluation results are themselves non-deterministic. Running the same test case twice may produce different scores, because the agent's behavior is non-deterministic and the judge LLM's evaluation is also non-deterministic. To get reliable regression signals, you need to run each test case multiple times and compare the distributions of scores, not just individual scores.
A practical approach is to run each test case three to five times, compute the mean and standard deviation of the scores, and flag a regression when the mean score drops by more than one standard deviation. This is not a perfect statistical test, but it is a reasonable heuristic that catches real regressions while avoiding too many false alarms.
CHAPTER 12: PROMPT DEBUGGING - THE ART OF TALKING TO YOUR AGENT
The prompt is the primary interface between the developer and the LLM at the heart of the agent. It is where you specify the agent's persona, its goals, its constraints, its tools, and its output format. It is also, in many cases, the primary source of bugs.
Prompt debugging is both an art and a science. The science part involves systematic experimentation: changing one thing at a time, measuring the effect, and drawing conclusions. The art part involves developing an intuition for how LLMs interpret language, which requires experience and a willingness to be surprised.
COMMON PROMPT BUGS AND HOW TO FIND THEM
The most common prompt bug is ambiguity. When a prompt is ambiguous, the LLM will interpret it in whatever way seems most likely given its training data, which may not be the way you intended. The fix is to be more specific. But identifying ambiguity in your own prompts is harder than it sounds, because you know what you meant, and it is easy to read your own meaning into ambiguous language.
A useful technique for finding prompt ambiguity is to ask a colleague to read your prompt and describe what they think the agent is supposed to do. If their description differs from yours, you have found an ambiguity. Another technique is to deliberately try to misinterpret your own prompt: read it as if you were trying to find a way to follow it literally while violating its spirit. If you can find such an interpretation, the LLM probably can too.
The second most common prompt bug is instruction conflict. When a prompt contains two instructions that cannot both be satisfied simultaneously, the LLM must choose which one to prioritize. The choice it makes may not be the one you would have chosen. Instruction conflicts are particularly common in complex system prompts that have been built up incrementally over time, with new instructions added without checking for conflicts with existing ones.
To find instruction conflicts, read your system prompt carefully and look for any two instructions that could, in some scenario, pull the agent in different directions. When you find a potential conflict, think through the scenarios in which it would manifest and decide which instruction should take priority. Then rewrite the prompt to make that priority explicit.
PROMPT VERSIONING AND A/B TESTING
One of the most important practices for managing prompt complexity is prompt versioning: treating your prompts as code, storing them in version control, and tracking which version of a prompt was used in each agent run. Without prompt versioning, it is impossible to know whether a change in agent behavior is due to a change in the prompt, a change in the model, or a change in the tool outputs.
Prompt versioning also enables A/B testing: running two versions of a prompt simultaneously, with different users or different test cases, and comparing the results. This is the most rigorous way to evaluate a prompt change, because it controls for all the other variables that might affect agent behavior.
A minimal prompt versioning system might store prompts as files in a Git repository, with the version identifier being the Git commit hash. Every agent run logs the commit hash of the prompt it used, making it possible to reconstruct the exact prompt for any historical run. More sophisticated systems use a dedicated prompt management platform like Langfuse's prompt management feature, which provides a web interface for editing and versioning prompts and a programmatic API for retrieving specific versions.
DEBUGGING PROMPT INJECTION VULNERABILITIES
Prompt injection is a security vulnerability, but it is also a debugging challenge. When an agent's behavior is being influenced by malicious content in its environment, the symptoms can look exactly like a model failure or a prompt bug. The agent starts doing something unexpected, and without a complete trace of its inputs, it is impossible to determine whether the unexpected behavior is coming from the model or from injected instructions.
The key to debugging prompt injection is to log the complete content of every tool output that is incorporated into the agent's context. If you can see exactly what text was inserted into the prompt at each step, you can identify injected instructions by looking for text that looks like system instructions or that directly contradicts the agent's original directives.
A simple but effective prompt injection detector scans tool outputs for patterns that are characteristic of injection attempts, such as phrases like "ignore previous instructions," "you are now," or "your new task is." When such patterns are detected, the output is flagged for human review before being incorporated into the agent's context.
CHAPTER 13: BUILDING A DEBUGGING WORKFLOW - PUTTING IT ALL TOGETHER
We have covered a lot of ground in this article. We have examined the anatomy of an agent, the nature of non-determinism, the taxonomy of failure modes, the techniques for logging and tracing, the specific debugging approaches for each component, and the tools and platforms available to support the debugging process. Now let us synthesize all of this into a practical debugging workflow that you can apply the next time your agent misbehaves.
STEP ONE: REPRODUCE OR RECONSTRUCT
The first step in any debugging session is to understand exactly what happened. If the bug is reproducible, reproduce it in a controlled environment with full logging enabled. If it is not reproducible - which is common with agentic AI - reconstruct the failure from the logs of the original run.
Your goal at this stage is to have a complete, step-by-step record of the agent's execution: every prompt, every LLM response, every tool call, and every observation. If your logging is comprehensive, this record should be available in your observability platform. If your logging is incomplete, this is the moment when you will regret it.
STEP TWO: IDENTIFY THE FAILURE POINT
With the execution record in hand, work backward from the failure to find the step where things first went wrong. The final failure - the wrong answer, the error message, the stuck loop - is rarely the root cause. It is usually the downstream consequence of an earlier failure.
Walk through the execution record step by step, asking at each step: is the input to this step correct? Is the output of this step correct? If both are correct, the failure is downstream. If the input is correct but the output is wrong, you have found the failure point. If the input is wrong, the failure is upstream.
This process is essentially a binary search through the execution history, and it is usually much faster than reading the entire trace from beginning to end.
STEP THREE: CLASSIFY THE FAILURE
Once you have identified the failure point, classify it using the taxonomy from Chapter 3. Is it a goal drift failure? A hallucination? A tool selection error? A parameter construction error? A context window overflow? A loop?
The classification tells you where to look for the fix. A hallucination points to the model or the retrieval system. A tool selection error points to the tool descriptions. A parameter construction error points to the tool schema or the output format. A context window overflow points to the memory management strategy.
STEP FOUR: ISOLATE THE COMPONENT
Once you know which component is responsible, isolate it and test it independently. If the failure is in the LLM's reasoning, construct the exact prompt that was sent at the failing step and submit it to the model directly, reading the response carefully. If the failure is in a tool, call the tool directly with the parameters the agent used and examine the result. If the failure is in the retrieval system, run the retrieval query directly and examine the returned documents.
Isolation is the key to efficient debugging. By removing the surrounding complexity of the full agent system, you can focus on the specific component that is failing and iterate quickly on potential fixes.
STEP FIVE: FIX, TEST, AND MONITOR
Once you have identified and isolated the failure, implement a fix and test it. Run the specific test case that exposed the bug and verify that the fix resolves it. Then run your full evaluation suite to verify that the fix does not introduce regressions.
After deploying the fix to production, monitor the relevant metrics closely for the first few hours. Watch for changes in error rates, tool call patterns, step counts, and evaluation scores. If the fix has introduced a regression, you want to catch it quickly.
CONCLUSION: EMBRACING THE CHAOS WITH DISCIPLINE
Debugging agentic AI systems is genuinely hard. It requires a different mental model than traditional software debugging, a different set of tools, and a different set of intuitions. The non-determinism of LLMs, the complexity of multi-step execution, the opacity of the model's internal reasoning, and the emergent behavior of multi-agent systems all conspire to make bugs difficult to reproduce, difficult to isolate, and difficult to fix without introducing new problems.
But hard is not the same as impossible. The techniques and tools described in this article - comprehensive logging, distributed tracing, structured tool outputs, loop detection, context window monitoring, tool mocking, LLM-as-judge evaluation, and systematic prompt debugging
- give you a powerful toolkit for approaching even the most baffling agent failures with methodical confidence.
The key insight is that debugging agentic AI is fundamentally an empirical discipline. You cannot reason your way to the answer from first principles. You need data: logs, traces, evaluation scores, and the patience to read through them carefully. The developers who are most effective at debugging agents are not necessarily the ones who understand LLMs most deeply at a theoretical level. They are the ones who have built the most comprehensive observability infrastructure and who have developed the discipline to use it systematically.
The field is young and moving fast. New tools, new techniques, and new failure modes are emerging every month. The specific platforms and libraries mentioned in this article will evolve, and some of them will be superseded by better alternatives. But the underlying principles - log everything, isolate components, think statistically, and test systematically - will remain relevant as long as we are building systems that use probabilistic models to make decisions in complex, uncertain environments.
Which, if current trends are any indication, will be for a very long time indeed.
So instrument your agents, read your traces, and may your loops always terminate.
REFERENCES AND FURTHER READING
The following sources informed the research and analysis in this article and are recommended for further reading.
On agent architecture and the ReAct pattern: the original ReAct paper by Yao et al. (2022) remains the foundational reference for the Thought-Action-Observation loop. The subsequent literature on agent architectures, including work on reflection, planning, and multi-agent coordination, builds extensively on this foundation.
On observability and tracing: the OpenTelemetry project documentation provides comprehensive guidance on distributed tracing standards. The OpenTelemetry Generative AI Observability Special Interest Group, which began work in April 2024, is developing standardized attribute names and span types specifically for AI workloads.
On evaluation: the LangSmith documentation provides practical guidance on LLM-as-judge evaluation. The Ragas framework is a widely used open-source library for evaluating RAG systems and agents.
On security and prompt injection: the OWASP Top 10 for Large Language Model Applications provides a comprehensive overview of security vulnerabilities in LLM-based systems, including prompt injection.
On multi-agent systems: research published in 2025 on coordination failures in multi-agent networks provides important empirical data on the amplification of errors in unstructured multi-agent architectures.