Agents · Guide
Designing AI Agents with Guardrails and Clear Failure Paths
An agent is a loop that lets a model choose actions. This guide covers how to bound that loop, constrain tools, validate arguments, and design an explicit path for when things go wrong.
- Author
- Maya Chen (sample)
- Published
- Reading time
- 10 min read
- 1
plan
next action from goal + state
- 2
select tool
allow-listed registry only
- 3
validate args
schema check before running
- 4
execute
timeout + idempotency key
- 5
observe
append result to state
- loop while goal not met
Exits
✓ respond
goal met, cite tool results
⏸ ask a human
approval required for write actions
✕ fail safely
step limit (8) or invalid tool call
An AI agent, stripped of marketing, is a loop: the model looks at the conversation so far, picks an action (usually a tool call), your code executes it, the result is appended, and the loop repeats until the model produces a final answer. That loop is powerful because the model decides the next step. It is risky for the same reason.
Guardrails are the engineering that keeps the loop predictable: limits on how long it runs, which tools it can touch, what arguments it may pass, and what happens when it can't succeed. This guide treats each of those as a design decision rather than an afterthought.
Why agents fail differently
A single LLM call fails in one place: the output is wrong. An agent can fail at every step, and errors compound. A slightly wrong search query returns irrelevant results, the model reasons from those results, and three steps later it confidently calls a tool with arguments that look plausible but are wrong.
- Runaway loops: the model keeps calling tools without converging, consuming tokens and time.
- Hallucinated tools or arguments: the model invents a tool name or passes a field your function doesn't accept.
- Unsafe side effects: a write action (refund, email, deletion) executes when it shouldn't.
- Silent failure: the agent gives up and returns a vague answer that looks like success.
Before adding guardrails, ask whether the task needs an agent at all. If the steps are known in advance — fetch the order, check the policy, draft a reply — a fixed workflow with one or two model calls is easier to test, cheaper to run, and fails in fewer ways. Reserve the open-ended loop for tasks where the sequence of actions genuinely depends on what earlier steps return.
Bound the loop
Every agent loop needs hard limits enforced by your code, not by instructions in the prompt. The model may ignore “please finish within five steps”; a for loop with a maximum will not.
- Step limit: a maximum number of model turns per task.
- Budget limit: a cap on total tokens or estimated cost per task.
- Wall-clock timeout: a deadline that covers slow tools as well as slow model calls.
- Repetition detection: stop if the agent calls the same tool with the same arguments repeatedly.
Choose limits from observed behavior. Trace a set of representative tasks, see how many steps successful runs typically take, and set the ceiling with some headroom. Runs that hit the limit are a signal worth reviewing, not just an error to swallow.
Constrain the tools
Allow-list per task
Give the agent only the tools the current task needs. A support agent answering an order-status question doesn't need a refund tool in its menu. Smaller tool sets reduce confusion for the model and reduce the blast radius if something goes wrong. Treat the allow-list as configuration that's reviewed, not something the model can extend.
Validate every argument
Tool arguments are untrusted input, exactly like a request body from the internet. Define a schema for each tool, validate before executing, and return validation errors to the model in plain language so it can correct itself. Also enforce authorization in the tool itself: the agent acting for user A must not be able to read user B's orders just because it guessed a valid ID.
A bounded tool-calling loop in code
The example below puts these ideas together: a step limit, an allow-listed tool registry, Pydantic argument validation, an approval gate on a write tool, and an explicit Escalation exception as the failure path. The llm.next_action call is a hypothetical wrapper around whichever model provider you use.
from collections.abc import Callablefrom dataclasses import dataclassfrom typing import Any from pydantic import BaseModel, ValidationError from app.llm import llm # hypothetical wrapper: llm.next_action(history, tools) -> Actionfrom app.tools import IssueRefundArgs, SearchOrdersArgs, issue_refund, search_orders MAX_STEPS = 8 @dataclass(frozen=True)class Tool: fn: Callable[[BaseModel], Any] args_model: type[BaseModel] needs_approval: bool = False TOOLS: dict[str, Tool] = { "search_orders": Tool(search_orders, SearchOrdersArgs), "issue_refund": Tool(issue_refund, IssueRefundArgs, needs_approval=True),} class Escalation(Exception): """The agent cannot safely finish; hand the task to a human.""" def run_agent(task: str) -> str: history: list[dict[str, str]] = [{"role": "user", "content": task}] for _ in range(MAX_STEPS): action = llm.next_action(history, tools=list(TOOLS)) if action.kind == "final": return action.text tool = TOOLS.get(action.tool_name) if tool is None: history.append({"role": "tool", "content": f"Unknown tool {action.tool_name!r}; allowed: {list(TOOLS)}"}) continue try: args = tool.args_model.model_validate(action.arguments) except ValidationError as err: history.append({"role": "tool", "content": f"Invalid arguments: {err.errors(include_url=False)}"}) continue if tool.needs_approval: raise Escalation(f"{action.tool_name} needs approval: {args.model_dump()}") history.append({"role": "tool", "content": str(tool.fn(args))}) raise Escalation(f"No final answer within {MAX_STEPS} steps")In a real system, the approval gate would usually pause the run and persist its state rather than raising, so a reviewer can approve and the agent can resume. Raising keeps the example short and makes the failure path impossible to miss.
Design the failure path deliberately
An agent that can't finish should say so clearly and hand off cleanly. Decide in advance what the user sees and what the receiving human gets.
- For the user: an honest message that the request needs a person, plus what happens next. Never a fabricated answer.
- For the reviewer: the original task, the steps taken, tool results, and the reason for escalation, so they don't start from zero.
- For the system: a structured escalation event with a reason code (step limit, validation loop, approval required, tool error) that you can count and trend.
Observe and review agent runs
Log every step as a structured trace: the model input, the chosen action, validated arguments, tool result, latency, and token usage. Without step-level traces, debugging an agent means guessing. With them, you can replay failing runs, turn them into eval cases, and see exactly where a plan went off course.
- Hard step, budget, and time limits are enforced in code.
- Each task exposes only the tools it needs.
- Every tool validates its arguments and enforces authorization itself.
- Side-effecting tools require approval and are idempotent where possible.
- Escalation produces a clear user message and a complete handoff record.
- Every step is traced, and failing runs are converted into eval cases.
Examples and figures in this article are illustrative. Adapt thresholds and tooling to your own system and data.