Back to Insights
AI & Machine LearningDebugging the Black Box: Why Real Observability (Session Replay, Error Tracking, Logs) Is the Missing Layer Making AI Agents Actually Work in Productiondeep diveSeptember 8, 202618 min read

Debugging the Black Box: Why Session Replay, Error Tracking, and Structured Logs Are the Missing Observability Layer for Production AI Agents

AI agents in production fail silently in non-deterministic ways. Learn the observability architecture—session replay, semantic error tracking, and structured logs—that makes them debuggable.

T
Tamiz UddinFull-Stack Engineer

Your AI agent deployed last Tuesday is serving 40,000 sessions a day. On Thursday, support tickets spike: users report the agent "gave the wrong answer" or "stopped working halfway through." You open your APM dashboard. All HTTP 200s. No exceptions thrown. p99 latency looks fine. The LLM provider's uptime page says everything's green. So what actually broke?

The answer: you can't tell. Not because the failure was subtle, but because the observability stack you built for your previous system—distributed tracing, request/response logging, error boundary alerts—was designed for deterministic, stateless services. An AI agent is none of those things. It's a multi-step, non-deterministic, stateful reasoning loop where the "request" is a conversation, the "response" is a plan, and the "exception" is a hallucination that looks perfectly confident.

This article breaks down why that gap exists, what the three missing pillars are (session replay, semantic error tracking, and structured agent logs), and how to architect them into a system that actually makes production AI agents debuggable. We'll include concrete code, a reference architecture, and the operational patterns that separate a "demo that works" from a production system you can operate at 3 a.m.

Table of Contents

1. Why Traditional Observability Fails for AI Agents

The core mismatch is epistemic. In a traditional microservice, causality is linear: request in, computation, response out. You trace the request with a correlation ID, sample spans at each hop, and when something breaks, the trace tells you where. The failure mode is a thrown exception, a timeout, a 5xx.

An AI agent inverts this model in four ways:

  • Non-determinism as the norm. The same input conversation can produce different tool calls, different reasoning chains, and different final answers on each run. You cannot assert assert result == expected. Your "test" is a probabilistic check, and your "bug" is a distribution shift, not a code path.

  • The conversation is the state. In a CRUD service, state lives in a database and you query it. In an agent, the critical state is the message history, the working memory, the tool-call context, and the agent's internal reasoning. None of that is in your database by default. It lives in the LLM's context window and evaporates when the session ends.

  • Failures are semantic, not syntactic. The agent might call the correct tool, get the correct data back, and then synthesize a response that is subtly wrong—confident, grammatically perfect, but factually incorrect. No exception fires. No 500 returns. The "error" is in the content, not the transport.

  • Multi-turn, long-lived sessions. A single user interaction might involve 12 LLM calls, 4 tool executions, and 20 seconds of wall-clock time. Traditional tracing assumes short, stateless requests. The unit of observability shifts from request to session.

The result: your error_rate metric says 0.02%. Your p99_latency is 4.2s. Your dashboard is green. And 3% of your users just got a wrong answer that cost a business deal.

2. The Three Missing Pillars

Pillar 1: Session Replay (The "What Actually Happened?" Layer)

Session replay for an AI agent is not a screen recording. It is a deterministic, queryable log of every reasoning step, tool invocation, LLM call, and state mutation that occurred during a session. Think of it as event sourcing applied to agent cognition.

What a complete session replay captures:

Event TypePayloadWhy It Matters
session.startuser_id, initial message, agent version, model configBaseline for the session
llm.callmodel, tokens_in, tokens_out, latency, full prompt/response, temperatureReproduce the exact reasoning step
tool.invoketool_name, args (JSON), result (truncated), duration, error?See what the agent did, not just what it said
agent.thinkinternal reasoning trace (if using CoT), decision, next actionUnderstand why the agent chose path B over path A
state.mutatebefore/after state diff, triggering eventTrack how working memory evolved
session.endfinal response, total tokens, total cost, success/failure classificationClosure and aggregate metrics

The critical property: you can replay any session by feeding the recorded event sequence back into the agent's logic. This turns a probabilistic, non-deterministic system into a debuggable one—given the same events, you can step through the logic and find where the chain diverged.

Pillar 2: Semantic Error Tracking (The "Is the Output Actually Wrong?" Layer)

Traditional error tracking (Sentry, Datadog Errors) catches exceptions. You need a second layer that catches semantic failures:

  • Hallucination detection: The agent cites a source that doesn't exist. You can't catch this with a try/catch. You need a post-hoc validator (an LLM-as-judge, or a RAG faithfulness check) that flags the response.
  • Tool misuse: The agent calls send_email with the recipient field set to the user's internal email instead of the customer email. The call succeeds (200 OK). The error is in the argument semantics.
  • Context overflow: The conversation grows to 28k tokens, the model's effective context is 32k, and the agent starts losing early context. No exception. Just degraded quality.
  • Refusal / policy violation: The agent hits a guardrail and refuses, but the user interprets this as "the agent is broken." Your error tracker should classify this as expected_refusal, not error.
  • Degraded tool responses: The external API returns a 200 with an empty result set. The agent hallucinates a workaround. The error is in the upstream data, not the agent code.

The key distinction: a technical error (exception, timeout, 5xx) vs. a semantic error (wrong answer, missed constraint, context loss). You need both, tagged, queryable, and correlated to the session replay.

Pillar 3: Structured Agent Logs (The "Why Did It Degrade?" Layer)

Unstructured console.log("here we are") is not observability. For agents, you need a structured log schema that is:

  • Correlatable: Every log line carries session_id, trace_id (linking back to the replay), and step_index.
  • Queryable: Stored in a columnar or log-olap system (ClickHouse, Datadog Logs, Loki) where you can run SELECT * FROM agent_events WHERE session_id = 'x' AND event_type = 'llm.call' AND tokens_out > 4096.
  • Hierarchical: A session contains steps; a step contains sub-calls. Parent-child relationships must be explicit.
  • Cost-aware: Every LLM call logs input_tokens, output_tokens, model_id, and a computed cost_usd so you can attribute spend per user, per feature, per bug.

3. Reference Architecture: Wiring It All Together

text
┌─────────────────────────────────────────────────────────────────────────┐
│                         USER CLIENT (Web / Mobile / API)                │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                      AGENT RUNTIME (Orchestrator)                       │
│  ┌─────────┐  ┌──────────┐  ┌───────────┐  ┌────────────────────┐    │
│  │ Planner │→ │ Executor │→ │ Validator │→ │ Response Builder   │    │
│  └─────────┘  └──────────┘  └───────────┘  └────────────────────┘    │
│       │              │              │                   │              │
│       ▼              ▼              ▼                   ▼              │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │              OBSERVABILITY INJECTION LAYER                       │   │
│  │  (wraps every LLM call, tool call, state mutation)             │   │
│  └─────────────────────────────────────────────────────────────────┘   │
└──────────────────────────────────┬─────────────────────────────────────┘
                                   │
                    ┌──────────────┼──────────────┐
                    ▼              ▼              ▼
          ┌──────────────┐ ┌─────────────┐ ┌────────────────┐
          │  EVENT BUS   │ │  SEMANTIC   │ │  STRUCTURED    │
          │  (NATS /     │ │  ERROR      │ │  LOG PIPELINE  │
          │   Kafka)     │ │  VALIDATOR  │ │  (Fluent Bit → │
          │              │ │             │ │   ClickHouse)  │
          └──────┬───────┘ └──────┬──────┘ └───────┬────────┘
                 │                │                 │
                 ▼                ▼                 ▼
          ┌──────────────┐ ┌─────────────┐ ┌────────────────┐
          │  SESSION     │ │  ALERTING  │ │  LOG OLAP      │
          │  STORE       │ │  (PagerDuty│ │  DASHBOARDS    │
          │  (S3 + DB)   │ │   + Slack) │ │  (Grafana)     │
          └──────────────┘ └─────────────┘ └────────────────┘

The critical design decision: the Observability Injection Layer is not an afterthought bolted onto the agent. It is a cross-cutting concern implemented as a middleware around every LLM call and tool invocation. This ensures you never forget to log a new tool you added in sprint 14.

In code terms, this looks like a decorator or wrapper that intercepts every call and emits structured events. We'll implement it concretely in the next section.

4. Implementation: Session Replay with Event Sourcing

The pattern below is language-agnostic; the example uses TypeScript with a generic agent loop. The core idea: every significant event in the session is an immutable record appended to a per-session event log.

typescript
// types.ts
import { v4 as uuidv4 } from "uuid";\n
export interface AgentEvent {
  event_id: string;
  session_id: string;
  trace_id: string;          // OpenTelemetry-compatible W3C trace ID
  step_index: number;        // ordinal position in the agent loop
  timestamp: string;         // ISO-8601
  event_type:
    | "session.start"
    | "llm.call"
    | "tool.invoke"
    | "agent.think"
    | "state.mutate"
    | "session.end";
  payload: Record<string, unknown>;
  parent_event_id?: string;  // links sub-calls to their parent step
}

export interface LLMPayload {
  model: string;
  provider: string;
  temperature: number;
  max_tokens: number;
  prompt_tokens: number;
  completion_tokens: number;
  cost_usd: number;
  latency_ms: number;
  prompt: string[];         // full message array (system, user, assistant)
  response: string;         // the model's output
  stop_reason: string;
}

export interface ToolPayload {
  tool_name: string;
  arguments: Record<string, unknown>;
  result: unknown;          // truncated to 4KB for storage
  duration_ms: number;
  error?: string;          // present only if the tool call failed
  http_status?: number;    // if the tool is an HTTP call
}
typescript
// observability.ts — the injection layer
import { AgentEvent, LLMPayload, ToolPayload } from "./types";
import { EventEmitter } from "events"; // or NATS/Kafka publisher

class ObservabilityBus extends EventEmitter {
  private eventSequence: Map<string, number> = new Map();
  private traceId: string;
  private sessionId: string;

  constructor(sessionId: string, traceId: string) {
    super();
    this.sessionId = sessionId;
    this.traceId = traceId;
  }

  nextStepIndex(): number {
    const idx = this.eventSequence.get(this.sessionId) ?? 0;
    this.eventSequence.set(this.sessionId, idx + 1);
    return idx;
  }

  emit(event: Omit<AgentEvent, "event_id" | "session_id" | "trace_id" | "step_index" | "timestamp">): void {
    const full: AgentEvent = {
      ...event,
      event_id: uuidv4(),
      session_id: this.sessionId,
      trace_id: this.traceId,
      step_index: this.nextStepIndex(),
      timestamp: new Date().toISOString(),
    };

    // 1. Persist to session store (append-only, S3 + metadata in Postgres)
    this.persist(full);

    // 2. Publish to real-time bus for alerting / dashboards
    this.publish(full);

    // 3. Emit locally for in-process subscribers (metrics, debug hooks)
    this.emit(full.event_type, full);
  }

  private persist(event: AgentEvent): void {
    // In production: append to an S3 object keyed by session_id,
    // or write to a per-session Kafka topic with compacted partition.
    // For this example, write to a local JSONL file.
    const fs = require("fs");
    fs.appendFileSync(
      `sessions/${event.session_id}.jsonl`,
      JSON.stringify(event) + "\n"
    );
  }

  private publish(event: AgentEvent): void {
    // NATS publish to subject: `agent.events.${event.session_id}`
    // or Kafka topic: `agent-events` with key = session_id
  }
}

Now the agent loop, instrumented:

typescript
// agent.ts
import { ObservabilityBus } from "./observability";
import { LLMClient } from "./llm-client";   // wraps OpenAI / Anthropic / local model
import { ToolRegistry } from "./tools";

export async function runAgentSession(
  sessionId: string,
  userMessage: string,
  llm: LLMClient,
  tools: ToolRegistry
): Promise<string> {
  const traceId = crypto.randomUUID(); // W3C traceparent
  const bus = new ObservabilityBus(sessionId, traceId);
  let messages: Array<{ role: string; content: string }> = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: userMessage },
  ];
  let workingMemory: Record<string, unknown> = {};

  bus.emit({
    event_type: "session.start",
    payload: { user_message: userMessage, agent_version: "v2.1.0", model: "claude-sonnet-4" },
  });

  const MAX_STEPS = 10;
  let step = 0;

  while (step < MAX_STEPS) {
    // --- LLM CALL ---
    const t0 = performance.now();
    const llmResult = await llm.chat(messages, { model: "claude-sonnet-4", temperature: 0.2 });
    const latency = performance.now() - t0;

    const llmPayload: LLMPayload = {
      model: "claude-sonnet-4",
      provider: "anthropic",
      temperature: 0.2,
      max_tokens: 4096,
      prompt_tokens: llmResult.usage.input_tokens,
      completion_tokens: llmResult.usage.output_tokens,
      cost_usd: llmResult.usage.input_tokens * 0.000003 + llmResult.usage.output_tokens * 0.000015,
      latency_ms: latency,
      prompt: messages,
      response: llmResult.content,
      stop_reason: llmResult.stop_reason ?? "end_turn",
    };

    bus.emit({ event_type: "llm.call", payload: llmPayload });

    // Did the model want to call a tool?
    const toolCall = parseToolCall(llmResult.content);

    if (toolCall) {
      // --- TOOL INVOCATION ---
      const toolT0 = performance.now();
      let toolResult: unknown;
      let toolError: string | undefined;
      try {
        toolResult = await tools.invoke(toolCall.name, toolCall.args);
      } catch (err) {
        toolError = err instanceof Error ? err.message : String(err);
      }
      const toolDuration = performance.now() - toolToolT0;

      const toolPayload: ToolPayload = {
        tool_name: toolCall.name,
        arguments: toolCall.args,
        result: truncate(JSON.stringify(toolResult), 4096),
        duration_ms: toolDuration,
        error: toolError,
      };

      bus.emit({ event_type: "tool.invoke", payload: toolPayload, parent_event_id: /* the llm.call event_id */ });

      // Append the tool result back into the conversation
      messages.push({ role: "assistant", content: llmResult.content });
      messages.push({ role: "user", content: `Tool result from ${toolCall.name}: ${JSON.stringify(toolResult)}` });

      // Track state mutations
      if (toolCall.name === "update_order") {
        bus.emit({
          event_type: "state.mutate",
          payload: { field: "order_status", before: workingMemory.order_status, after: "pending_review" },
        });
        workingMemory.order_status = "pending_review";
      }
    } else {
      // No tool call: the model produced a final answer.
      messages.push({ role: "assistant", content: llmResult.content });

      bus.emit({
        event_type: "session.end",
        payload: {
          final_response: llmResult.content,
          total_steps: step + 1,
          total_tokens: sumTokens(messages),
          total_cost_usd: sumCost(messages),
        },
      });

      return llmResult.content;
    }
    step++;
  }

  bus.emit({ event_type: "session.end", payload: { terminated: true, reason: "max_steps_exceeded" } });
  return "[Agent reached step limit without final answer]";
}

The key insight: you never manually remember to log a new tool or a new reasoning step. The injection layer is the logging. If you add a search_web tool in the next sprint, it goes through tools.invoke(), which is already wrapped. The observability coverage grows with the agent automatically.

5. Implementation: Semantic Error Tracking

Semantic errors are the hard part. You need a post-hoc validator that inspects the agent's output and flags issues that no exception would catch. The architecture:

typescript
// semantic-validator.ts
interface SemanticValidationResult {
  session_id: string;
  is_error: boolean;
  error_class:
    | "hallucination_detected"
    | "tool_misuse"
    | "context_overflow"
    | "policy_refusal"
    | "upstream_data_missing"
    | "none";
  confidence: number;       // 0.0 – 1.0
  evidence: string;         // human-readable explanation
  affected_step: number;    // which step in the replay triggered this
}

async function validateSession(
  events: AgentEvent[],
  sessionEnd: AgentEvent
): Promise<SemanticValidationResult> {
  const llmCalls = events.filter(e => e.event_type === "llm.call");
  const toolCalls = events.filter(e => e.event_type === "tool.invoke");

  // 1. Context overflow check
  const lastCall = llmCalls[llmCalls.length - 1] as { payload: LLMPayload };
  if (lastCall.payload.prompt_tokens > 28000 && lastCall.payload.model.includes("32k")) {
    return {
      session_id: events[0].session_id,
      is_error: true,
      error_class: "context_overflow",
      confidence: 0.92,
      evidence: `Prompt at step ${lastCall.step_index} used ${lastCall.payload.prompt_tokens} tokens; model context is 32k. Early messages likely evicted.`,
      affected_step: lastCall.step_index,
    };
  }

  // 2. Tool misuse: check that no tool returned an empty result that the agent ignored
  for (const tc of toolCalls as Array<{ payload: ToolPayload; step_index: number }>) {
    if (tc.payload.result === "null" || tc.payload.result === "[]" || tc.payload.result === "{}") {
      // Check the *next* LLM call: did the agent pretend the data existed?
      const nextLlm = llmCalls.find(l => l.step_index > tc.step_index);
      if (nextLlm && /'there is'|found|retrieved/.test(nextLlm.payload.response)) {
        return {
          session_id: events[0].session_id,
          is_error: true,
          error_class: "upstream_data_missing",
          confidence: 0.85,
          evidence: `Tool '${tc.payload.tool_name}' returned empty at step ${tc.step_index}, but the agent's next LLM call references data as if it existed.`,
          affected_step: nextLlm.step_index,
        };
      }
    }
  }

  // 3. Hallucination: LLM-as-judge (expensive, run on sampled sessions)
  if (sample(sessionId, 0.05)) { // 5% sampling to control cost
    const judgePrompt = buildFaithfulnessJudgePrompt(events);
    const judgeResult = await llm.judge(judgePrompt);
    if (judgeResult.confidence > 0.8 && judgeResult.verdict === "unfaithful") {
      return {
        session_id: events[0].session_id,
        is_error: true,
        error_class: "hallucination_detected",
        confidence: judgeResult.confidence,
        evidence: judgeResult.explanation,
        affected_step: judgeResult.step_index,
      };
    }
  }

  return { session_id: events[0].session_id, is_error: false, error_class: "none", confidence: 1.0, evidence: "", affected_step: -1 };
}

Route these results into your error tracking system (Sentry, Honeybadger, or a custom Postgres table). The critical difference from traditional error tracking: the error_class is semantic, not technical. You page on hallucination_detected rate > 2% over 5 minutes, not on a 500 response code.

6. Implementation: Structured Agent Logs

The log pipeline should produce JSON lines that are immediately queryable in a log-olap engine. Here's the schema and the pipeline:

json
{
  "ts": "2025-07-14T03:22:17.441Z",
  "session_id": "a1b2c3d4-…",
  "trace_id": "00-4bf92f3577b341668677d5211077cf3b-…",
  "step_index": 4,
  "event_type": "llm.call",
  "agent_version": "v2.1.0",
  "model": "claude-sonnet-4",
  "provider": "anthropic",
  "tokens_in": 3240,
  "tokens_out": 187,
  "cost_usd": 0.016,
  "latency_ms": 2310,
  "tool_called": "query_database",
  "tool_success": true,
  "semantic_error": null,
  "user_tier": "enterprise",
  "feature_flag": "agentic_search_v2"
}

Pipeline (Fluent Bit → ClickHouse):

text
Agent Runtime  →  JSONL files / NATS
     │
     ▼
Fluent Bit (or Vector / OpenTelemetry Collector)
     │  - parse JSON
     │  - enrich with service metadata
     │  - redact PII (user emails, card numbers)
     ▼
ClickHouse (log_olap table, TTL 90 days)
     │
     ▼
Grafana / Metabase dashboards
     + PagerDuty alerting rules

The ClickHouse table DDL that makes this queryable:

sql
CREATE TABLE agent_events (
    ts            DateTime64(3),
    session_id    String,
    trace_id      String,
    step_index    UInt32,
    event_type    LowCardinality(String),
    agent_version LowCardinality(String),
    model         LowCardinality(String),
    tokens_in     UInt32,
    tokens_out    UInt32,
    cost_usd      Float64,
    latency_ms    UInt32,
    tool_called   LowCardinality(String),
    tool_success  UInt8,
    semantic_error String,  -- empty string = none
    user_tier     LowCardinality(String),
    feature_flag  LowCardinality(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (session_id, ts)
TTL ts + INTERVAL 90 DAY;

Now the query that answers "why did this user get a bad answer?":

sql
SELECT step_index, event_type, model, tokens_in, tokens_out, cost_usd, latency_ms,
       tool_called, tool_success, semantic_error
FROM agent_events
WHERE session_id = 'a1b2c3d4-…'
ORDER BY step_index;

And the aggregation that tells you "our cost per successful session crossed a threshold":

sql
SELECT toStartOfHour(ts) AS hour,
       count() AS sessions,
       sum(tokens_in + tokens_out) AS total_tokens,
       sum(cost_usd) AS total_cost,
       countIf(semantic_error != '') AS semantic_errors,
       round(100.0 * countIf(semantic_error != '') / count(), 2) AS error_rate_pct
FROM agent_events
WHERE event_type = 'session.end'
GROUP BY hour
ORDER BY hour DESC
LIMIT 48;

7. Operational Patterns and Production Edge Cases

Sampling strategy. You cannot store full prompt/response payloads for every call at 40k sessions/day without S3 costs eating your infra budget. The pattern:

  • Store full payloads for sessions flagged with semantic_error != '' or user_tier = 'enterprise'.
  • Store truncated payloads (first/last 500 chars of prompt and response) for everything else.
  • Run the LLM-as-judge validator on a 5% random sample; escalate to 100% during incidents.

PII and compliance. Agent conversations will contain user PII, possibly PHI or PII in regulated industries. Your observability pipeline must: (a) redact PII at the Fluent Bit / OTel Collector stage, (b) encrypt S3 buckets at rest, (c) apply shorter TTLs to PII-bearing fields. Do not send raw user conversations to a third-party SaaS observability vendor without a BAA / DPA review.

Non-determinism in replay. Because LLM outputs vary with temperature, replaying a recorded session by re-invoking the LLM will not produce identical results. What you can replay deterministically is the tool call sequence and state mutations. The LLM outputs in the replay are logged artifacts, not re-executed calls. Your replay tool should let you step through the logged events and inspect each decision point, not re-run the agent.

Cross-agent observability. If your system has multiple agents (a router agent that dispatches to specialist agents), the trace_id must propagate across agent boundaries, just like OpenTelemetry spans propagate across microservices. The parent agent's event log should reference the child agent's session_id as a linked trace.

Cost anomaly alerts. Beyond error rates, set up alerts on per-session cost. A single user's infinite tool-call loop (agent keeps calling search because the results are empty and it never converges) can cost $12 in a 90-second session. Alert when cost_usd > $0.50 per session or when step_index > 8.

Testing the observability layer itself. A meta-risk: if your instrumentation code has a bug, you lose visibility. Add integration tests that assert: (a) every LLM call in a recorded session has a matching llm.call event, (b) every tool invocation has a tool.invoke event with tool_success populated, (c) the session.end event's total_tokens equals the sum of all prompt_tokens + completion_tokens across the session.

For deeper patterns on structuring AI systems for testability, see Tamiz's Insights on AI engineering and the tamiz.pro collection of agent architecture write-ups.

8. Frequently Asked Questions

Q: Do I need all three pillars (replay, semantic tracking, structured logs) from day one?

No. Start with structured agent logs (Pillar 3)—it's the cheapest and gives you 80% of the debuggability. Add session replay (Pillar 1) once you have more than two LLM calls per session and need to step through multi-step reasoning. Add semantic error tracking (Pillar 2) when your NPS or support tickets start complaining about quality, not crashes. The three are complementary, not sequential.

Q: How does this compare to using an LLM-observability SaaS like LangSmith, Arize, or Braintrust?

Those platforms implement variations of the three pillars out of the box and are excellent for prototyping. The gap in production is usually: (a) they don't integrate with your existing log-olap / ClickHouse / data warehouse, (b) their retention and cost model scales linearly with LLM calls (which can get expensive at 40k sessions/day), and (c) you lose the ability to run arbitrary SQL across agent events joined with your business data (CRM, billing, support tickets). A self-hosted pipeline using the patterns above gives you that join. You can also use the SaaS for rapid prototyping and the self-hosted pipeline for production.

Q: What's the minimum viable observability stack for a new agent going to production this week?

Three things: (1) Emit structured JSON logs for every LLM call and tool call, with session_id, step_index, tokens, cost, and latency. Ship them to any log aggregator. (2) Add a session.end event with a success/failure classification. (3) Set up one Grafana alert: "sessions with step_index > 7 in the last 10 minutes > 5." That last alert catches the infinite-loop failure mode that is the #1 way agents die in production before anyone notices.