Why AI Agents Keep Lying to Themselves — And What Sandboxing, Audit Trails, and Honest Agent Design Actually Solve A developer on tamiz.pro argues that AI agents' tendency to fabricate outputs stems from a structural flaw in the ReAct loop, where a stateless LLM's only record of its actions is text tokens in its context window, lacking a verified execution trace. This leads to escalating hallucination rates in multi-turn tasks, and the author suggests sandboxing and audit trails as mitigations, though they don't prevent the lying itself. Originally published on tamiz.pro. You've seen it in production: an AI agent confidently fabricates a bank balance that doesn't exist, invents a file path that isn't real, or claims a function succeeded when it silently failed. These aren't user errors or bad prompts — they're the natural output of self-interpreting language models working in complex loops. This isn't just a "hallucination problem" in the colloquial sense. It's a structural engineering failure in how agents observe, reason, and act on their own outputs. The core issue is that today's dominant agent architecture — the ReAct loop Reason + Act — asks a stateless LLM to simultaneously think through a problem, call tools, observe results, and revise its mental model, all within a single streaming context window. The model has no ground truth anchor between turns. Its past actions are just text tokens in its context. It cannot verify whether what it says happened actually happened. This is why agents lie to themselves with alarming consistency. Understanding the mechanics of agent self-deception is prerequisite to building defenses that actually work. Let's go under the hood. At the fundamental level, an agent's "memory" of its own prior actions is nothing more than tokens in a context buffer. When an agent calls get balance account id="12345" and the tool returns {"balance": 94200} , both the action and the observation are encoded as text. The next turn, the model reads those tokens and generates its next thought. There is no separate, verified execution trace. The model could easily misread its own observation, conflate it with prior observations, or fabricate a new one entirely. This is especially dangerous in long-horizon tasks where the context window grows to thousands of tokens. The signal-to-noise ratio for the model's own prior actions degrades dramatically. Empirical studies have shown hallucination rates climbing from ~5% in single-turn tool use to 20–40% in multi-turn agent loops exceeding 10 steps. In a well-designed system, an action produces an observable effect in the world, and the observation is grounded in that effect. In a standard ReAct agent, this coupling is purely textual. The model generates an action string like read file path="/data/config.json" , the runtime executes it, and the result is appended to context. But the model itself has no causal link to the execution. It doesn't know the file was read — it only sees the text that follows its own token. When the tool response is large, noisy, or ambiguous, the model frequently performs interpretive reconstruction : it summarizes, paraphrases, or even invents what it thinks the response should say, rather than faithfully representing the actual output. This is not a bug in the model's training — it's an inherent property of autoregressive generation trying to compress high-dimensional reality into low-dimensional text. LLMs are optimized for fluency and plausibility, not truthfulness. Their loss function rewards generating the next token that makes the sequence coherent, not the next token that is factually accurate. When an agent is pressed through multiple reasoning steps, confidence compounds. A model that starts with a mild misreading will generate increasingly confident but increasingly wrong subsequent thoughts, because each new token is conditioned on the previous flawed reasoning. This creates a feedback loop: the agent becomes more certain of its incorrect model as it generates more text around it. The longer the chain of reasoning, the harder it is for the agent to self-correct — not because it lacks the capability, but because the context is dominated by its own authoritative-sounding but fabricated traces. Sandboxing doesn't prevent an agent from lying — it prevents the lie from causing damage. The key insight is architectural separation: isolate the agent's execution environment so that even a fully hallucinated action sequence can't reach production resources with unmitigated authority. A sandbox creates a bounded execution context with enforced boundaries: Without these boundaries, a single hallucinated tool call e.g., delete database database="production" executed with broad credentials causes irreversible damage. With proper sandboxing, that same hallucinated call either fails at the policy layer or operates only in an isolated test environment. Here's a production-grade sandbox pattern using a policy-enforced tool executor: python import json import hashlib from datetime import datetime from contextlib import contextmanager from typing import Any, Callable Define the sandbox policy schema class SandboxPolicy: def init self, allowed paths: list str , allowed hosts: list str , max retries: int = 3 : self.allowed paths = p.rstrip '/' for p in allowed paths self.allowed hosts = allowed hosts self.max retries = max retries self.audit log: list dict = def resolve path self, request path: str - str: """Ensure the requested path is within an allowed directory.""" base = self.allowed paths 0 if self.allowed paths else "." resolved = f"{base}/{request path}" real = hashlib.sha256 resolved.encode .hexdigest :16 if not any resolved.startswith p for p in self.allowed paths : raise PermissionError f"Path {request path} not in allowed sandboxes" return resolved def check host self, host: str - bool: return any host.endswith h for h in self.allowed hosts or host in self.allowed hosts def log self, action: str, tool: str, args: dict, result: Any, timestamp: datetime : self.audit log.append { "timestamp": timestamp.isoformat , "action": action, "tool": tool, "args": args, "result summary": self. summarize result , "result ok": isinstance result, dict and result.get "ok", True , } def summarize self, result: Any - str: if isinstance result, str : return result :200 + "..." if len result 200 else "" if isinstance result, dict : return json.dumps result, sort keys=True :200 return str result :200 class SandboxToolExecutor: def init self, policy: SandboxPolicy : self.policy = policy self. retry count: dict str, int = {} @contextmanager def execute self, tool name: str, tool args: dict, fn: Callable : """Execute a tool call within sandbox policy constraints.""" if tool name not in self. retry count: self. retry count tool name = 0 if self. retry count tool name = self.policy.max retries: return { "ok": False, "error": f"{tool name}: exceeded max retries {self.policy.max retries} ", "policy enforced": True, } Apply policy transformations before execution safe args = {} for key, value in tool args.items : if key == "path" or key.endswith " path" : safe args key = self.policy.resolve path str value elif key == "url" or key.endswith " url" : parsed = str value if not self.policy.check host parsed.split "://" -1 .split "/" 0 : return { "ok": False, "error": f"Host {parsed} not in allowed list", "policy enforced": True, } safe args key = parsed else: safe args key = value try: result = fn safe args self. retry count tool name = 0 Reset on success return result except Exception as e: self. retry count tool name += 1 return { "ok": False, "error": str e , "retry remaining": self.policy.max retries - self. retry count tool name , "policy enforced": False, } Example: define sandbox-scoped tool functions def read file path: str - dict: with open path, "r" as f: return {"ok": True, "content": f.read , "bytes": len f.read } def write file path: str, content: str - dict: with open path, "w" as f: f.write content return {"ok": True, "bytes written": len content } def query service url: str, params: dict - dict: In production, this would be an actual HTTP call through a proxy return {"ok": True, "data": {"status": "simulated response"}} Initialize with strict policy policy = SandboxPolicy allowed paths= "/tmp/agent workspace" , allowed hosts= "api.internal.example.com", "db.internal.example.com" , max retries=3, executor = SandboxToolExecutor policy This pattern ensures that every tool call passes through a policy enforcement layer before touching real resources. The critical design choice: the agent never sees the policy decisions directly in its tool results — the sandbox transparently resolves paths and blocks disallowed hosts, returning controlled error responses the model can reason about without escalating to broader permission requests. It's important to be precise about the boundaries. Sandboxing contains damage but doesn't improve the quality of the agent's reasoning. A sandboxed agent can still hallucinate successfully within its bounded environment — it might confidently invent data, make incorrect API calls that succeed in the sandbox, or build flawed plans based on fabricated observations. The lies are still happening; they're just contained. If sandboxing is the brake system, audit trails are the dash cam. They don't prevent the accident, but they make it possible to understand exactly what happened, when, and why — which is the foundation for any remediation or model improvement. A production-grade audit trail for AI agents needs to capture five dimensions: // TypeScript type definitions for an agent audit trail interface AuditAction { id: string; timestamp: ISO8601; step: number; actor: "agent" | "system" | "human"; // The model's stated thought/reasoning thought: string; thoughtTokens?: number; thoughtConfidence?: number; // Log-probability of generated tokens // Tool interaction toolCall?: { name: string; arguments: Record