DEV Community

Hossein Hezami
Hossein Hezami

Posted on

When an AI Agent Makes a Mistake in Production, Which Layer Should Stop It?

A familiar production failure looks like this: an AI support agent reads a ticket, decides the customer deserves compensation, calls the refund tool, and refunds the full annual subscription instead of the $12 add-on. The model did not crash. The API did not throw an exception. The tool worked exactly as designed.

The postmortem usually starts with the wrong question: “How do we stop the model from making bad decisions?”

The better question is: which layer should have stopped the mistake before it became damage?

AI agents fail in many different ways. They misunderstand intent. They create dangerous plans. They pass malformed arguments. They exceed permissions. They loop. They leak data. They take irreversible actions. Each failure mode belongs to a different layer, and each layer has a different job.

If your only defense is a prompt that says, “Be careful,” you do not have a safety architecture. You have a hope.

TL;DR: AI agent mistakes should not be stopped by the model alone. Use layered defense: intent classification stops wrong missions, plan validation stops forbidden sequences, tool schemas stop invalid arguments, authorization stops unauthorized actions, execution controls limit blast radius, output validation catches harmful results, runtime monitors stop loops, and human approval guards asymmetric risk. The best stopping layer is the earliest deterministic layer that can prevent harm, with the final brake closest to irreversible side effects.

📋 Table of Contents

The Mistake Is Not One Failure Mode

Before choosing a layer, name the failure.

An AI agent can make mistakes in at least six distinct ways:

  1. Wrong intent: The user asks for a summary, and the agent treats it as an instruction to modify data.
  2. Bad plan: The agent chooses a sequence of steps that is technically possible but operationally dangerous.
  3. Invalid tool use: The agent calls a real tool with wrong, missing, or overly broad arguments.
  4. Unauthorized action: The agent does something the current user, tenant, or environment should not allow.
  5. Harmful output: The agent writes an email, generates code, produces SQL, or returns data that should not be exposed.
  6. Runaway execution: The agent loops, retries, overspends, or keeps escalating side effects.

These are not the same problem. They should not be solved by the same layer.

A good agent safety model looks less like a single guardrail and more like a series of gates. Some gates are cheap and probabilistic. Some are strict and deterministic. Some exist to reduce frequency. Others exist to prevent catastrophe.

The core rule:

The closer an action is to irreversible harm, the more deterministic the stopping layer must be.

1. The Prompt Layer Should Persuade, Not Enforce

Scenario:

Your agent has a system prompt that says, “Never delete customer data. Always ask for confirmation before refunds. Do not expose internal notes.” Then one day, a confusing ticket, a weird tool result, and a slightly ambiguous user request combine into an action the prompt explicitly forbade.

Why it matters:

Prompts are useful. They shape behavior, tone, priorities, and general caution. But prompts are probabilistic instructions, not enforcement boundaries. They reduce the likelihood of mistakes; they do not make mistakes impossible.

If your safety case depends on the model obeying a sentence, you have no safety case.

Solution:

Use prompts for guidance, not for security.

Good prompt-layer responsibilities:

  • Explain the agent’s role.
  • Define preferred decision order.
  • Tell the agent when to ask for clarification.
  • Tell it to prefer read-only tools before write tools.
  • Explain how to present uncertainty.
  • Provide examples of safe and unsafe reasoning.

Bad prompt-layer responsibilities:

  • Tenant isolation.
  • Authorization.
  • Preventing destructive database operations.
  • Blocking refunds above a threshold.
  • Stopping external emails to unverified recipients.
  • Guaranteeing that secrets are never leaked.

A reasonable system prompt may include policy context:

You are a support operations agent.

Rules:
- Prefer read-only tools when investigating.
- Do not propose refunds above the policy limit without approval.
- If the request is ambiguous, ask one clarifying question.
- Never invent customer data.
- If a tool result says "approval_required", stop and request human review.
Enter fullscreen mode Exit fullscreen mode

That helps. But it is not the boundary.

Why this works:

The prompt becomes what it is good at: steering. It lowers the chance that the agent proposes something dumb. It does not carry the weight of production safety.

⚠️ Gotcha:

If a postmortem ends with “the model ignored the prompt,” the real failure is that a prompt was treated as a control plane.

2. The Intent Layer Should Catch the Wrong Mission

Scenario:

A user asks, “Clean up the stale test records.” The agent interprets “stale” as “older than one day” and targets the production user table. The tool calls are valid. The permissions are valid. The mission is wrong.

Why it matters:

Many agent failures begin before planning or tool use. They begin with an overly broad interpretation of the goal. If the intent layer lets a vague request become a high-risk mission, later layers may not have enough context to stop it.

Solution:

Parse the request into a typed intent object before the agent starts planning.

That object should capture:

  • The goal.
  • The likely risk class.
  • The affected scope.
  • Whether the task is read-only, reversible, or irreversible.
  • Whether clarification is required.
from enum import Enum
from pydantic import BaseModel, Field
from typing import Literal


class RiskLevel(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


class ParsedIntent(BaseModel):
    goal: str
    risk: RiskLevel
    scope: Literal["single_record", "bounded_set", "global", "unknown"]
    side_effects: Literal["none", "reversible", "irreversible"]
    needs_clarification: bool = False


def route_intent(intent: ParsedIntent):
    if intent.scope == "global" and intent.risk != RiskLevel.LOW:
        return "require_human_scoping"

    if intent.side_effects == "irreversible":
        return "require_plan_approval"

    if intent.needs_clarification:
        return "ask_user"

    return "allow_agent_planning"
Enter fullscreen mode Exit fullscreen mode

The exact parsing can be model-assisted, but the routing rules should be deterministic. A high-risk scope should not silently become a normal agent run.

Why this works:

You stop the mistake while it is still cheap. A wrong intent caught before planning costs almost nothing. A wrong intent caught after three tool calls may already have changed state.

💡 Practical note:

“Global” is the most dangerous word in agent operations. If the agent cannot describe the affected set precisely, it should not be allowed to modify anything.

3. The Planning Layer Should Reject Forbidden Paths

Scenario:

The agent produces a plan: “List all inactive users, then delete each user.” Each tool exists. Each call is syntactically valid. The sequence is still terrible.

Why it matters:

Tool-level validation is not enough. Some mistakes emerge from combinations. Reading a list is safe. Deleting one record may be safe. Reading a list and then deleting everything in it is a different operation.

Solution:

Validate the plan before execution.

Plan validation should check:

  • Forbidden tool sequences.
  • Excessive affected counts.
  • Missing dry-run steps.
  • Writes without preceding reads.
  • External communication before verification.
  • Escalation from one record to many records.
  • Actions that bypass approval workflows.
from pydantic import BaseModel


class PlanStep(BaseModel):
    tool: str
    args: dict


FORBIDDEN_SEQUENCES = {
    ("list_users", "delete_user"),
    ("search_orders", "bulk_refund_orders"),
    ("read_ticket", "send_external_email"),
}


def validate_plan(steps: list[PlanStep]) -> None:
    for i in range(len(steps) - 1):
        pair = (steps[i].tool, steps[i + 1].tool)
        if pair in FORBIDDEN_SEQUENCES:
            raise PermissionError(f"Forbidden plan sequence: {pair}")

    for step in steps:
        if step.tool == "delete_user" and step.args.get("all"):
            raise PermissionError("Bulk delete requires explicit bounded scope")

        if step.tool == "bulk_refund_orders" and not step.args.get("dry_run"):
            raise PermissionError("Bulk refund must start as dry run")
Enter fullscreen mode Exit fullscreen mode

This layer does not need to understand the entire business. It needs to know the shapes of dangerous behavior.

Why this works:

Plans are easier to inspect than runtime side effects. Once the plan is rejected, no tool gets a chance to make the mistake real.

🧠 The important part:

Do not only validate individual tool calls. Some failures are only visible in the sequence.

4. The Tool Contract Layer Should Make Invalid Actions Unrepresentable

Scenario:

Your agent has a tool called run_query(query: str). The model sends DELETE FROM users WHERE last_login < '2025-01-01'. The database accepts it. The agent was not trying to be malicious; it was just using the tool you gave it.

Why it matters:

If a tool can represent a catastrophic action, the model will eventually find that representation. Vague tools are not flexible; they are liabilities.

Solution:

Design tools so invalid or dangerous actions cannot be expressed.

Prefer narrow capabilities over general primitives.

Bad tool:

def run_sql(query: str) -> list[dict]:
    ...
Enter fullscreen mode Exit fullscreen mode

Better tool:

from pydantic import BaseModel, Field
from typing import Literal


class ArchiveInactiveUsersInput(BaseModel):
    inactive_days: int = Field(ge=30, le=3650)
    limit: int = Field(gt=0, le=100)
    tenant_id: str
    dry_run: bool = True
    reason: str = Field(min_length=10, max_length=500)


def archive_inactive_users(input: ArchiveInactiveUsersInput) -> dict:
    if input.dry_run:
        count = count_inactive_users(input.tenant_id, input.inactive_days, input.limit)
        return {
            "status": "dry_run",
            "would_archive": count,
            "limit": input.limit,
        }

    # Actual execution would still require policy checks, idempotency,
    # and an audit record.
    ...
Enter fullscreen mode Exit fullscreen mode

Good tool contracts include:

  • Typed parameters.
  • Bounded limits.
  • Enums instead of free-text operation names.
  • Required tenant or user context.
  • Default dry-run for destructive operations.
  • Explicit reasons or tickets for auditability.
  • Structured error results the agent can understand.

Why this works:

You move safety from runtime judgment into the shape of the system. The agent cannot pass all=True if all=True does not exist.

🚨 Production warning:

If an agent can write arbitrary SQL, shell commands, HTTP requests, or template code, you have not built a tool layer. You have built an interpreter.

5. The Authorization Layer Should Veto Even Correct-Looking Actions

Scenario:

The agent calls refund_order(order_id=8821, amount_cents=120000). The arguments are valid. The order exists. The refund tool works. But the current user is a support contractor who is only allowed to refund up to $25.

Why it matters:

Model confidence is not authorization. A tool call can be reasonable, correctly formatted, and still forbidden for the current actor, tenant, environment, or resource state.

Solution:

Enforce authorization inside the tool execution path, using the same policy machinery you would use for a human or service client.

The policy decision should consider:

  • Who is the actor?
  • Which tenant does the actor belong to?
  • Which resource is being acted on?
  • What is the action?
  • What is the amount, scope, or blast radius?
  • Is the resource in a state that allows the action?
  • Is the environment production, staging, or development?
from dataclasses import dataclass


@dataclass(frozen=True)
class Actor:
    user_id: str
    tenant_id: str
    role: str
    max_refund_cents: int


@dataclass(frozen=True)
class RefundAction:
    order_id: str
    tenant_id: str
    amount_cents: int
    order_status: str


def authorize_refund(actor: Actor, action: RefundAction) -> tuple[bool, str]:
    if actor.tenant_id != action.tenant_id:
        return False, "tenant_mismatch"

    if action.order_status not in {"delivered", "returned", "failed"}:
        return False, "order_not_refundable"

    if action.amount_cents > actor.max_refund_cents:
        return False, "amount_exceeds_actor_limit"

    return True, "allowed"
Enter fullscreen mode Exit fullscreen mode

In larger systems, this is where policy engines, attribute-based access control, or resource-scoped tokens belong. The exact tooling matters less than the principle: the model does not get to approve itself.

Why this works:

Authorization becomes a deterministic gate. Even if the agent’s reasoning is flawed, the system can refuse the action.

🔍 Why this matters:

If your agent uses one highly privileged service account for all users, you have moved authorization from your product into the model’s vibes.

6. The Execution Layer Should Make Side Effects Boring

Scenario:

The agent tries to create a support ticket. The first request times out. The agent retries. The first request actually succeeded. Now there are three tickets, and the agent proudly reports success.

Why it matters:

Many production mistakes are not dramatic. They are duplicate writes, partial updates, retried payments, repeated emails, or batch jobs that run too broadly. The execution layer is where abstract plans become real-world consequences.

Solution:

Make execution safe by default.

The execution layer should provide:

  • Idempotency keys.
  • Transaction boundaries.
  • Batch size limits.
  • Rate limits.
  • Cost budgets.
  • Timeouts.
  • Dry-run modes.
  • Reversible operations where possible.
  • Clear success/failure semantics.
  • Audit records before and after mutation.
from dataclasses import dataclass


@dataclass(frozen=True)
class RefundRequest:
    order_id: str
    amount_cents: int
    idempotency_key: str


def execute_refund(request: RefundRequest, store, payments, policy) -> dict:
    existing = store.get_by_idempotency_key(request.idempotency_key)
    if existing:
        return existing

    if request.amount_cents > policy.max_auto_refund_cents:
        return {
            "status": "approval_required",
            "reason": "amount_exceeds_auto_limit",
            "idempotency_key": request.idempotency_key,
        }

    result = payments.refund(
        order_id=request.order_id,
        amount_cents=request.amount_cents,
        idempotency_key=request.idempotency_key,
    )

    response = {
        "status": result.status,
        "order_id": request.order_id,
        "amount_cents": request.amount_cents,
        "idempotency_key": request.idempotency_key,
    }

    store.save_idempotent_result(request.idempotency_key, response)
    return response
Enter fullscreen mode Exit fullscreen mode

The key detail is that the agent does not decide whether a retry is safe. The system makes retries safe.

For bulk operations, add explicit limits:

def execute_bulk_archive(tenant_id: str, limit: int, dry_run: bool):
    if limit > 100:
        raise ValueError("Bulk archive limit is 100 per execution")

    if dry_run:
        return {"status": "dry_run", "affected": count_candidates(tenant_id, limit)}

    affected = archive_candidates(tenant_id, limit)
    return {"status": "completed", "affected": affected}
Enter fullscreen mode Exit fullscreen mode

Why this works:

Even when the agent makes a bad choice, the damage is bounded. A mistake that affects one record is an incident. A mistake that affects one million records may be a company-ending event.

7. The Output Layer Should Catch Harmful Results Before They Ship

Scenario:

The agent drafts a support email. The content is polite, accurate, and includes the customer’s internal account notes, a reset token, or another customer’s order ID.

Why it matters:

Not every agent mistake is an action. Some mistakes are outputs. The agent may generate unsafe SQL, reveal private data, produce misleading claims, write code with secrets, or send a message that creates a legal problem.

Solution:

Validate outputs before they reach users, downstream systems, or execution environments.

Output controls depend on the output type.

For text:

  • Check for secrets.
  • Check for PII that should not be exposed.
  • Check for forbidden commitments.
  • Check for unsupported claims when grounding is required.
  • Check tone and escalation requirements.

For code:

  • Block network calls if not allowed.
  • Block filesystem access if not allowed.
  • Block shell execution.
  • Limit dependencies.
  • Run in a sandbox.

For SQL:

  • Allow only read-only statements.
  • Reject DDL and DML unless explicitly authorized.
  • Require a bounded LIMIT.
  • Validate against a known schema.

A simple email output check might look like this:

from pydantic import BaseModel, Field


class DraftEmail(BaseModel):
    to: str
    subject: str = Field(max_length=200)
    body: str = Field(max_length=5000)


def validate_support_email(email: DraftEmail, policy) -> tuple[bool, str]:
    if not email.to.endswith(policy.allowed_customer_domain):
        return False, "recipient_not_allowed"

    lowered = email.body.lower()

    if "internal note" in lowered:
        return False, "internal_content_leak"

    if "guaranteed refund" in lowered and not policy.allows_refund_guarantee:
        return False, "unauthorized_commitment"

    if contains_secret_pattern(email.body):
        return False, "possible_secret_leak"

    return True, "ok"
Enter fullscreen mode Exit fullscreen mode

This is not a replacement for authorization. It is a final content boundary.

Why this works:

The output layer catches mistakes after generation but before consumption. That matters because users and downstream systems often trust whatever the agent produces.

⚠️ Gotcha:

Output validation is not just for chat responses. Generated code, tool arguments, emails, tickets, SQL, and configuration changes are all outputs.

8. The Runtime Monitor Should Stop Slow-Motion Failures

Scenario:

The agent calls a search tool, gets no useful result, slightly rephrases the query, tries again, gets no useful result, and repeats. Twenty-five steps later, the request is expensive, slow, and no closer to an answer.

Why it matters:

Some agent mistakes are not single bad actions. They are patterns: loops, retries, escalating scope, rising cost, repeated authorization denials, or repeated validation failures. These failures are visible in motion, but not always visible in a single step.

Solution:

Add a runtime monitor with the authority to pause or stop the agent.

Track:

  • Step count.
  • Token usage.
  • Cost.
  • Latency.
  • Tool failure rate.
  • Authorization denials.
  • Repeated tool calls.
  • Repeated argument hashes.
  • Lack of state progress.
  • Excessive write operations.
  • Escalation from read-only to destructive tools.
from collections import deque
from dataclasses import dataclass


@dataclass(frozen=True)
class StepEvent:
    step: int
    tool: str
    args_hash: str
    succeeded: bool


class LoopDetector:
    def __init__(self, window_size: int = 5):
        self.events = deque(maxlen=window_size)

    def add(self, event: StepEvent) -> None:
        self.events.append(event)

    def is_stuck(self) -> bool:
        if len(self.events) < self.events.maxlen:
            return False

        same_tool = len({event.tool for event in self.events}) == 1
        same_args = len({event.args_hash for event in self.events}) == 1
        all_failed = all(not event.succeeded for event in self.events)

        return same_tool and same_args and all_failed
Enter fullscreen mode Exit fullscreen mode

The monitor should not merely log. It needs a response policy:

  • If the agent repeats the same failed call, stop.
  • If cost exceeds budget, stop.
  • If step count exceeds limit, stop.
  • If authorization fails twice, escalate.
  • If write volume spikes, pause.
  • If the agent switches from investigation to destructive action too quickly, require review.

Why this works:

Runtime monitoring treats agent behavior as a process, not a single request. It catches mistakes that emerge over time.

💡 Practical note:

An alert without a brake is just expensive telemetry. The monitor needs to be able to pause, degrade, or terminate the run.

9. The Human Approval Layer Should Guard Asymmetric Risk

Scenario:

The agent wants to email 2,000 customers about a billing change. The plan is coherent. The tool arguments are valid. The authorization policy technically allows the action. But if the agent is wrong, the cost is large and public.

Why it matters:

Some mistakes are cheap to fix. Others are not. A wrong internal note can be edited. A wrong refund can sometimes be reversed. A wrong message sent to thousands of customers cannot be unsent.

Human approval should not be the default for everything. If every action requires approval, you get approval fatigue, rubber-stamping, and an agent that provides no value. But for asymmetric risk, human review is the right final gate.

Solution:

Use approval gates selectively.

Require human approval when:

  • The action is irreversible.
  • The action affects many records.
  • The action is external: email, SMS, payment, public post, API partner call.
  • The action changes permissions, billing, security settings, or infrastructure.
  • The action is above a monetary threshold.
  • The action is in production.
  • The agent’s confidence is low but the blast radius is high.
from dataclasses import dataclass


@dataclass(frozen=True)
class ProposedAction:
    tool: str
    affected_count: int
    side_effect: str
    is_external: bool
    is_production: bool
    estimated_cost_cents: int


def requires_human_approval(action: ProposedAction) -> bool:
    if action.side_effect in {"delete", "refund", "send_email", "change_permission"}:
        return True

    if action.affected_count > 10:
        return True

    if action.is_external:
        return True

    if action.is_production and action.estimated_cost_cents > 5000:
        return True

    return False
Enter fullscreen mode Exit fullscreen mode

The approval request should include enough context for a human to decide quickly:

  • What the agent wants to do.
  • Why it wants to do it.
  • Which evidence supports the action.
  • What the expected effect is.
  • What the reversible alternative is.
  • What happens if no action is taken.

Why this works:

Humans are not good at supervising every small action, but they are still necessary for decisions where the downside is large, public, legal, financial, or irreversible.

Decision Framework: Where Should the Brake Go?

The answer to “which layer should stop it?” depends on the mistake.

Mistake type Example Best first stopping layer Final safety layer
Wrong intent User asks for info, agent modifies data Intent classification Tool authorization
Dangerous plan List all records, then delete them Plan validation Execution limits
Bad arguments Agent passes all=true or empty filter Tool schema Database/service constraints
Unauthorized action Agent refunds order it should not touch Authorization policy Audit + rollback
Overbroad action Agent updates 100,000 rows Scope limits + dry run Batch caps + human approval
Harmful output Agent leaks PII in email Output validation Human review
Runaway loop Agent retries same failed tool 30 times Runtime monitor Cost/step budget
Irreversible external action Agent emails customers or deletes data Human approval Approval + audit
Model hallucination Agent invents policy or customer fact Grounding + verification Output validation

A useful way to think about it:

Early layers reduce frequency.

Prompts, intent parsing, planning, and tool design make mistakes less likely.

Late layers reduce severity.

Authorization, execution limits, output validation, approval gates, and rollback make mistakes survivable.

You need both.

If you only invest in early layers, you will eventually be surprised. If you only invest in late layers, your agent will be blocked so often that it becomes useless.

The practical priority is:

  1. Make dangerous actions impossible to express.
  2. Make unauthorized actions impossible to execute.
  3. Make authorized actions bounded and reversible.
  4. Make high-risk actions require explicit approval.
  5. Make failures visible enough to improve the system.

Production Checklist

Before letting an AI agent take production actions, I would want these controls in place.

Prompt layer

  • The system prompt explains role, limits, and escalation behavior.
  • The prompt does not carry security responsibility.
  • The agent is instructed to prefer read-only tools before writes.

Intent layer

  • The agent parses user requests into structured intent.
  • Risk level and scope are identified before planning.
  • Ambiguous high-risk requests require clarification.

Planning layer

  • Plans are inspectable.
  • Forbidden tool sequences are blocked.
  • Bulk operations require bounded scope.
  • Destructive operations require dry-run steps.

Tool layer

  • Tools are narrow and typed.
  • Dangerous primitives are not exposed.
  • Arguments have limits, enums, and required context.
  • Tool errors are structured and safe to feed back to the agent.

Authorization layer

  • Tools execute with actor context, not one magic admin token.
  • Tenant isolation is enforced.
  • Resource state is checked.
  • Amount, scope, and role limits are enforced outside the model.

Execution layer

  • Write operations use idempotency keys.
  • Bulk operations have hard caps.
  • External calls have timeouts and rate limits.
  • Dry run is available for destructive operations.
  • Audit logs capture before/after state.

Output layer

  • User-facing text is checked for leaks and forbidden commitments.
  • Generated code is sandboxed.
  • Generated SQL is restricted.
  • Structured outputs are validated before use.

Runtime layer

  • Step, token, and cost budgets exist.
  • Loop detection can stop repeated failed calls.
  • Authorization denials trigger escalation.
  • Sudden transitions from read-only to destructive behavior trigger review.

Human layer

  • High-risk actions require approval.
  • Approval requests include evidence and expected impact.
  • Approval is not required so often that humans start rubber-stamping.

The model is the most creative part of an AI agent, but it should not be the most trusted part. The system’s job is to let the model reason while making sure its mistakes stay small, visible, and reversible.

When an agent fails in production, the best layer to stop it is the one that turns that failure from an incident into a rejected request.

Top comments (0)