Free lesson · GenAI Agent Engineering

Debug agent-specific issues

You can debug LLM-driven agent failures: unexpected tool selection, prompt drift, multi-step agent loops, and surface the right inputs/intermediate state to a debugging assistant for analysis.

Course: GenAI Agent Engineering · Chapter 7 · The Debugger

Free to read — no subscription required.

Introduction

When your agent silently picks the wrong tool, loops forever, or returns a half-parsed response, ordinary print debugging is not enough — the failure lives inside a multi-step interaction between the LLM, the prompt, and your control loop. Teams that ship agents without dedicated tracing, state-diff, and loop-detection tooling burn hours chasing bugs that a structured trace would surface in seconds, and they sometimes ship runaway loops to production that quietly drain token budgets. By the end of this lesson you will be able to instrument an agent with method-level tracing, capture comparable state snapshots between steps, and detect both repeated-state and oscillation loops so you can localise the four most common agent-specific failure modes before they reach users.

Key Terminology

  • Execution trace — a time-ordered log of every method ENTER, EXIT, and FAIL across an agent's run, with arguments, results, and elapsed time; matters because it turns an opaque multi-step run into a grep-able timeline.
  • Trace decorator — a functools.wraps-preserving wrapper applied to agent methods that emits trace events without changing return values or suppressing exceptions, and works uniformly for sync and async methods.
  • State snapshot — an immutable copy of agent fields (messages, tool history, step count, token estimate) captured at one moment; matters because comparing two snapshots is how you prove what actually changed between steps.
  • State fingerprint — a small dictionary summarising the agent's current state (last tool, message count, response hash) used to compare iterations cheaply; full message lists are too expensive to compare on every loop tick.
  • Oscillation loop — an A→B→A→B pattern where the agent alternates between two near-identical states; matters because plain repeated-state detection misses it and it is the most common runaway pattern when tool selection flips between two candidates.

Concepts

The four agent-specific failure modes

Generic web-service debugging breaks down on agents because the bugs live across iterations, not inside a single request. Four failure modes account for most production incidents: wrong-tool selection (the LLM picks a plausible but incorrect tool), response-parsing failure (the LLM returns valid prose that does not match your action regex), state corruption (messages or tool history grow in unexpected shapes), and infinite loops (the agent never reaches its terminal condition). Each mode has a distinct diagnostic signature, so route each symptom to the right instrument rather than reaching for one universal debugger.

Loading diagram...

Tracing as the default instrument

Method-level tracing is the cheapest first move. A decorator wraps each agent method, logs ENTER/EXIT with arguments and elapsed time, and indents nested calls so the trace reads like a stack. The wrapper must be transparent — it cannot change return values, cannot swallow exceptions, and must work for both sync and async methods because real agents mix both. Output is a flat text log you can grep, diff, or paste into a ticket — no debugger attach required, and it survives in production behind a single enabled flag (see Code Walkthrough).

State snapshots and diffs

When you need to know what changed between two steps, traces are too noisy. State snapshots compress the agent's state to a comparable dictionary — message count, last tool, and a token estimate — and a diff function reports the delta. This is the right tool for "why did context size jump 4× on step 7?" and for catching tool-history regressions where an expected tool call disappears.

Loop detection via fingerprint similarity

Infinite loops come in two flavours. Plain repeated-state loops are caught by comparing each iteration's fingerprint against all previous ones with a similarity threshold (0.95 catches functional duplicates while tolerating timestamp drift). Oscillation loops require comparing the last four fingerprints for an A→B→A→B pattern, because the agent never produces the same state twice — it just flips between two. Both checks are cheap because the fingerprint is a three- or four-key dict, not a full message list.

Code Walkthrough

Now that you've seen four agent-specific failure modes, tracing as the default instrument, state snapshots and diffs, and loop detection via fingerprint similarity, this walkthrough turns them into working code.

The combined DebugAgent below shows the three instruments working together: the @trace decorator records every method call, capture_snapshot plus diff_snapshots exposes what changed between steps, and LoopGuard.check rejects both repeated-state and oscillation patterns. Read it as one pipeline — the trace tells you what ran, the diff tells you what changed, and the guard tells you when to stop.

Code snippetpython
1import functools 2import inspect 3from datetime import datetime 4from typing import Callable, Dict, List, Tuple 5 6def trace(func: Callable) -> Callable: 7 """Log ENTER / EXIT / FAIL with args, result, elapsed time.""" 8 is_async = inspect.iscoroutinefunction(func) 9 10 async def _async(*args, **kwargs): 11 start = datetime.now() 12 print(f"ENTER {func.__name__} args={args[1:]} kwargs={kwargs}") 13 try: 14 result = await func(*args, **kwargs) 15 print(f"EXIT {func.__name__} ({(datetime.now()-start).total_seconds():.3f}s)") 16 return result 17 except Exception as e: 18 print(f"FAIL {func.__name__}: {type(e).__name__}: {e}") 19 raise 20 21 def _sync(*args, **kwargs): 22 start = datetime.now() 23 print(f"ENTER {func.__name__} args={args[1:]} kwargs={kwargs}") 24 try: 25 result = func(*args, **kwargs) 26 print(f"EXIT {func.__name__} ({(datetime.now()-start).total_seconds():.3f}s)") 27 return result 28 except Exception as e: 29 print(f"FAIL {func.__name__}: {type(e).__name__}: {e}") 30 raise 31 32 return functools.wraps(func)(_async if is_async else _sync) 33 34class LoopGuard: 35 """Detects repeated-state loops and A->B->A->B oscillation.""" 36 37 def __init__(self, max_iters: int = 50, similarity: float = 0.95): 38 self.max_iters = max_iters 39 self.similarity = similarity 40 self.fingerprints: List[Dict] = [] 41 42 def _fingerprint(self, state: Dict) -> Dict: 43 return { 44 "last_tool": state.get("last_tool"), 45 "msg_count": len(state.get("messages", [])), 46 "resp_hash": hash(str(state.get("last_response", ""))[:200]), 47 } 48 49 def _similar(self, a: Dict, b: Dict) -> float: 50 matches = sum(1 for k in a if k in b and a[k] == b[k]) 51 return matches / max(len(a), len(b)) if a else 0.0 52 53 def check(self, state: Dict) -> Tuple[bool, str]: 54 i = len(self.fingerprints) 55 if i >= self.max_iters: 56 return True, f"max iterations ({self.max_iters}) reached" 57 fp = self._fingerprint(state) 58 for j, prev in enumerate(self.fingerprints): 59 if self._similar(prev, fp) >= self.similarity: 60 return True, f"state at iter {i} matches iter {j}" 61 self.fingerprints.append(fp) 62 if len(self.fingerprints) >= 4: 63 r = self.fingerprints[-4:] 64 if self._similar(r[0], r[2]) > 0.9 and self._similar(r[1], r[3]) > 0.9: 65 return True, "oscillation (A->B->A->B)" 66 return False, "" 67 68class DebugAgent: 69 """Agent wrapped in tracing, snapshots, and loop guard.""" 70 71 def __init__(self): 72 self.messages: List[Dict] = [] 73 self.last_tool: str | None = None 74 self.last_response: str = "" 75 self.snapshots: List[Dict] = [] 76 self.done: bool = False 77 78 def capture_snapshot(self, label: str) -> Dict: 79 snap = { 80 "label": label, 81 "msg_count": len(self.messages), 82 "last_tool": self.last_tool, 83 "tokens": sum(len(m.get("content", "")) for m in self.messages) // 4, 84 } 85 self.snapshots.append(snap) 86 return snap 87 88 def diff_snapshots(self, a: Dict, b: Dict) -> None: 89 delta_msgs = b["msg_count"] - a["msg_count"] 90 delta_tok = b["tokens"] - a["tokens"] 91 print(f"DIFF {a['label']} -> {b['label']}: " 92 f"msgs {a['msg_count']}->{b['msg_count']} ({delta_msgs:+d}), " 93 f"tokens {delta_tok:+d}, tool {a['last_tool']!r}->{b['last_tool']!r}") 94 95 @trace 96 async def run(self, query: str) -> str: 97 self.messages.append({"role": "user", "content": query}) 98 prev = self.capture_snapshot("initial") 99 guard = LoopGuard(max_iters=20) 100 while not self.done: 101 stop, reason = guard.check({ 102 "last_tool": self.last_tool, 103 "messages": self.messages, 104 "last_response": self.last_response, 105 }) 106 if stop: 107 raise RuntimeError(f"loop detected: {reason}") 108 await self._step() 109 curr = self.capture_snapshot(f"step_{len(self.snapshots)}") 110 self.diff_snapshots(prev, curr) 111 prev = curr 112 return self.last_response 113 114 @trace 115 async def _step(self) -> None: 116 # Stand-in for tool selection + LLM call; real impl calls self.llm. 117 self.last_tool = "search" 118 self.last_response = "stub answer" 119 self.messages.append({"role": "assistant", "content": self.last_response}) 120 self.done = True

You'll know it works when a single await agent.run(query) call prints a nested ENTER/EXIT trace for run and _step, one DIFF line per step showing message-count and token deltas, and — if you remove the self.done = True line — a RuntimeError: loop detected: ... after at most 20 iterations rather than hanging.

Do's and Don'ts

Having walked through debugging agent-specific issues above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do enable tracing behind a flag from day one — retrofitting a @trace decorator into a running agent is far cheaper than reconstructing an incident from logs after the fact.
  2. Do snapshot-diff between steps, not just at the end — the step where message count or token usage jumps unexpectedly is usually the bug; only inter-step diffs surface it.
  3. Do check for oscillation, not just repetition — A→B→A→B is the most common runaway pattern and a naive "same state twice" check will miss it forever.

Don'ts

  1. Don't reach for a generic Python debugger first — agent bugs live across iterations, and stepping line-by-line through one call tells you nothing about why step 7 differed from step 6.
  2. Don't fingerprint on full message content — hash a 200-char prefix of the last response instead; full-content comparison is slow and over-sensitive to harmless whitespace drift.
  3. Don't silence a loop by raising max_iterations — that hides the bug and burns tokens; investigate why fingerprints repeat before changing the cap.

This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in GenAI Agent Engineering

All free lessons in GenAI Agent Engineering