~/TechPurAI
~/tutorials/building-ai-agents/common-mistakes-building-ai-agents
advanced·part 21 of 22·11 min read

Common Mistakes Building AI Agents

Updated Aug 31, 2026AI

Every part in this series flagged one mistake in its own Callout, as it came up. This part collects all 13 in one place, each with the code that causes it and the code that fixes it — a reference to check any agent against before it ships, not new material on top of the series.

Mistake 1: calling something an "agent" that never actually loops (part 1)

Severity: moderate — a naming problem more than a bug, but it sets the wrong expectations.

python
# no loop — this calls the model once and returns, so it can never
# decide to call a tool, look at the result, and react to it
def run_agent(user_message: str) -> str:
    response = client.messages.create(
        model="claude-sonnet-5",
        messages=[{"role": "user", "content": user_message}],
    )
    return response.content[0].text
python
# a real loop: the model can call a tool, see the result, and
# decide whether it needs another step before answering
def run_agent(user_message: str, max_iterations: int = 5) -> str:
    messages = [{"role": "user", "content": user_message}]
    for _ in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-5", messages=messages, tools=TOOLS,
        )
        if response.stop_reason != "tool_use":
            return response.content[-1].text
        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": execute_tool_calls(response.content)})
    return "Reached max_iterations without a final answer."

Cost: calling the first version an "agent" in a product spec or a customer-facing doc promises multi-step autonomy it can't deliver. Part 1 covers the actual distinction — the fix here is calling it what it is (a prompted, grounded call) until it genuinely loops.

Mistake 2: building an agent for a task answerable in one step (part 1, part 2)

Severity: moderate — a cost and latency problem, not a correctness one.

For a lookup like "what's the status of order #4821?", a full agent loop routes the request through the model, waits for a tool-call decision, executes the tool, and sends the result back for a second model call before answering. A single, direct function call gets the same answer in one round trip:

python
def get_order_status(order_id: str) -> str:
    order = db.orders.get(order_id)
    return f"Order {order_id} is currently {order.status}."

Cost: every extra iteration through the model is a full API round trip — for a one-fact lookup like this, the agent version costs 3-4x the latency and token spend of the direct version for no gain in reliability. Part 2 has the fuller decision criteria for when a loop is actually needed.

Mistake 3: writing vague, unclear tool descriptions (part 4, part 5)

Severity: high — this one directly degrades tool-selection accuracy.

python
{
    "name": "update_record",
    "description": "Handles updates.",
    "input_schema": {"type": "object", "properties": {"id": {"type": "string"}, "data": {"type": "object"}}},
}
python
{
    "name": "update_customer_billing_email",
    "description": (
        "Updates the billing email on a GreenDesk customer account. "
        "Use only when the customer explicitly asks to change their "
        "billing email. Requires the account's numeric customer_id, "
        "not the company name — look that up first if you only have "
        "the company name."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "customer_id": {"type": "string", "description": "Numeric GreenDesk customer ID"},
            "new_email": {"type": "string"},
        },
        "required": ["customer_id", "new_email"],
    },
}

Cost: with the vague version, the model reaches for the wrong tool, or the right tool with the wrong argument, often enough to show up in eval failure rates. The fix costs nothing but specificity — part 5 walks through writing descriptions the model can actually act on.

Mistake 4: designing overly broad tools with internal "mode" parameters (part 5)

Severity: moderate

python
{
    "name": "manage_customer",
    "input_schema": {
        "properties": {
            "mode": {"type": "string", "enum": ["get", "update", "delete"]},
            "customer_id": {"type": "string"},
            "data": {"type": "object"},
        }
    },
}
python
# three narrow tools instead of one overloaded one
GET_CUSTOMER = {"name": "get_customer", "input_schema": {...}}
UPDATE_CUSTOMER = {"name": "update_customer", "input_schema": {...}}
DELETE_CUSTOMER_ACCOUNT = {"name": "delete_customer_account", "input_schema": {...}}

Cost: a single tool with a mode switch forces the model to guess two things at once — which operation, and which arguments that operation needs. Splitting by operation removes the first guess entirely.

Mistake 5: relying on prompt instructions alone for a safety boundary (part 10, part 20)

Severity: critical

python
system = "You may only ever run read-only SQL queries against this database."
result = db.execute(model_generated_sql)  # nothing actually stops a write
python
def run_query(sql: str):
    if not sql.strip().upper().startswith("SELECT"):
        raise ValueError("Only SELECT statements are permitted.")
    return db.execute(sql)

# stronger still: connect with a database role that has SELECT-only
# grants, so an enforcement bug in this function isn't the last line
# of defense

Cost: a prompt instruction is a suggestion the model usually follows — it is not something the database enforces. A crafted input, a prompt-injected instruction from an earlier tool result, or just an unlucky generation can produce an UPDATE or DELETE statement that a prompt-only boundary does nothing to stop. Part 20 covers this in full.

Mistake 6: no timeout or retry logic on external tool calls (part 9)

Severity: high

python
response = requests.get(f"https://api.greendesk.example/customers/{customer_id}")
python
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],
)))
response = session.get(
    f"https://api.greendesk.example/customers/{customer_id}",
    timeout=(3.05, 10),
)

Cost: a single transient network blip fails the whole agent task where a retry would have quietly succeeded — and with no timeout, a hung connection can stall the entire agent loop indefinitely instead of failing fast. This is the same retry pattern covered in full in the Python requests library guide.

Mistake 7: an agent loop with no iteration limit (part 6, part 18)

Severity: high

python
while True:
    response = client.messages.create(model="claude-sonnet-5", messages=messages, tools=TOOLS)
    if response.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": execute_tool_calls(response.content)})
python
MAX_ITERATIONS = 8
for i in range(MAX_ITERATIONS):
    response = client.messages.create(model="claude-sonnet-5", messages=messages, tools=TOOLS)
    if response.stop_reason != "tool_use":
        break
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": execute_tool_calls(response.content)})
else:
    escalate_to_human("Agent hit max_iterations without resolving the task.")

Cost: an agent stuck retrying a failing tool call in an unbounded loop generates unlimited API cost with no ceiling — this is the single most expensive mistake on this list to leave unfixed in production.

Mistake 8: conflating working, session, and long-term memory (part 7)

Severity: moderate

python
# writes every message to a persistent table, regardless of whether
# any of it needs to outlive this one conversation
def log_message(session_id, role, content):
    db.messages.insert(session_id=session_id, role=role, content=content)
python
# working memory: just the in-process list for this turn
messages = [{"role": "user", "content": user_message}]

# long-term memory: only written when a fact is genuinely durable
# across sessions, via an explicit call — not every message by default
def remember_preference(customer_id: str, fact: str):
    db.customer_facts.insert(customer_id=customer_id, fact=fact)

Cost: defaulting to persistent storage for every message builds real infrastructure for data that never needed to survive past the current request — and the opposite mistake, never persisting anything, throws away genuinely useful information (a stated preference, a past issue) that the next session has no way to recall. Part 7 and part 8 cover choosing correctly.

Mistake 9: reaching for a multi-agent architecture by default (part 15, part 16)

Severity: moderate

Three coordinating sub-agents — a router, a fetcher, a summarizer — for "what's my order status?" is three model calls and three failure points for a task one tool call answers directly. Multi-agent coordination earns its cost on tasks that genuinely split into independent, parallelizable sub-problems (like the three-sub-agent GreenDesk onboarding system later in this series) — not as a default architecture choice.

Cost: meaningfully more API spend and more places for a run to fail, for a task a single well-scoped agent — or no agent at all — would have handled just as well.

Mistake 10: forcing upfront planning onto a genuinely open-ended task (part 17)

Severity: moderate

python
plan = generate_full_plan(research_question)  # decides all steps up front
for step in plan:
    execute(step)
python
# re-plan after each observation instead of committing up front
observation = None
for _ in range(MAX_ITERATIONS):
    next_step = decide_next_step(research_question, observation)
    if next_step is None:
        break
    observation = execute(next_step)

Cost: open-ended research can't have its correct next step known until a prior step's real result is seen — a search result that turns up nothing changes what should happen next in a way no upfront plan anticipated. Part 17 covers when upfront planning helps versus hurts.

Mistake 11: an agent that fails silently or fabricates an answer over an unresolved error (part 18)

Severity: critical

python
try:
    result = call_tool(name, args)
except Exception:
    result = "Done."  # the model now believes this succeeded
python
try:
    result = call_tool(name, args)
except Exception as err:
    result = f"Tool error: {err}. Do not assume this action succeeded — report the failure."

Cost: this is worse than a crash. A clear failure stops the process; a fabricated success looks fine, gets reported to the user as fine, and the actual failure surfaces later — somewhere much more expensive to trace back. Part 18 covers the full recovery pattern.

Mistake 12: no structured tracing for a non-deterministic system (part 19)

Severity: high

python
# no logging at all — when a run goes wrong, there's nothing to look at
python
def log_step(iteration: int, tool_name: str, args: dict, result: str, latency_ms: float):
    trace.append({
        "iteration": iteration, "tool": tool_name, "args": args,
        "result_preview": result[:200], "latency_ms": latency_ms,
    })

Cost: with no structured trace, understanding why an agent took an unexpected path on one specific run means guessing — the model's non-determinism means the same input won't reliably reproduce the same run to debug against. Part 19 covers building this properly.

Mistake 13: giving a consequential-action toolkit to an agent that processes untrusted content (part 20)

Severity: critical

python
# one agent, both research and action tools — a prompt-injected
# instruction inside a fetched webpage now has a path to send_email
# or delete_record
TOOLS = [read_webpage, search_web, send_email, delete_record]
python
# split by trust boundary: the research agent only ever reads
RESEARCH_TOOLS = [read_webpage, search_web]

# the action agent's tools are consequential, and it never receives
# raw untrusted content directly as tool-call arguments — only a
# human-reviewed summary produced by the research agent
ACTION_TOOLS = [send_email, delete_record]

def run_action_agent(summary: str, requires_confirmation: bool = True):
    if requires_confirmation and not human_confirms(summary):
        return "Action cancelled — awaiting human confirmation."
    ...

Cost: a single agent with both read access to untrusted external content and access to real, consequential tools is a structural prompt-injection path — content on a page the agent reads can contain instructions the model treats as legitimate. Part 20 covers this threat model directly.

The thread connecting all thirteen

Almost every mistake above comes from adding an agent's capability — tool use, memory, multi-step autonomy — without the engineering discipline that capability demands: precise tool design, safety boundaries enforced in code rather than in a prompt, structured observability, and honest scoping to tasks that need an agent at all.

Common mistake

Building the capability first and adding safety, tracing, and error handling afterward, once something has already broken in production. Every project earlier in this series built the safeguard directly alongside the capability it protects — part 6's agent got its iteration limit in the very next part, not months later after a costly runaway loop.

FAQ

Which of these mistakes matters most if I can only fix one? Mistake 5 (prompt-only safety boundaries) and Mistake 13 (untrusted content plus consequential tools) are the two with a real security blast radius — both are worth fixing before an agent gets any access to production data, ahead of anything else on this list.

Do these apply to a single-tool agent, or only multi-agent systems? All 13 apply to a single agent with one tool. Mistake 9 is specifically about not reaching for multiple agents by default — the multi-agent pattern it references only earns its cost once a task genuinely splits into independent sub-problems.

Is a while True loop always wrong? Not inherently — it's wrong without a bound. An explicit max_iterations with a defined behavior when it's hit (escalate, return a partial answer, fail loudly) is the fix, not switching to a different loop construct.

How is this different from the production-readiness checklist? This page is a post-mortem reference — the failure patterns and their fixes. The production-readiness checklist is a pre-launch gate — a pass/fail list to run through before an agent ships. Use this page to understand why each checklist item exists; use the checklist to decide whether a specific agent is ready.

Do these mistakes show up the same way in a RAG pipeline? Some overlap (no timeout/retry, no tracing), but RAG-specific failures — chunking, retrieval relevance, embedding drift — are a different layer covered in Common Mistakes with RAG and Embeddings.

Next, and last: the capstone — a complete production-readiness checklist bringing every part of this series together.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous20. Security Risks in AI Agentsnext →22. The Capstone: A Real Production-Ready Agent Checklist