Building in Public: Part 3

The Model Was Fine. The Behavior Wasn't.

Why AI agents get stuck in loops and skip their own tools. Two bugs that looked like the model failing but were really the wiring around it: a state reducer that appended instead of replaced, and an agent that wrote prose instead of calling a tool.

J.M.8 min read

You asked one question. The agent answered it ten times in a row, streaming the same card until something forced it to stop. It wasn't confused about the answer. It was confused about when to be done.

AI agents get stuck in loops when the state you feed back into them grows worse on every pass, and they skip their own tools when writing text is easier than calling one. Neither is the model failing to reason. Both are the engineering layer around the model handing it bad conditions and getting a bad result. Last time I wrote about agents making up numbers they didn't have. This is the other half of the same lesson: when an agent behaves badly, look at the wiring before you blame the model.

We've shipped fixes for 30+ bugs building Campaign Agents across Google, Meta, LinkedIn, and Reddit. The hallucinations are loud and scary. These two are quieter and, honestly, more humbling, because in both cases the model did exactly what we set it up to do. We just set it up wrong.

The Problem

An AI agent is not a single call to a model. It's a control loop. The agent reads its state, picks an action, the action returns a result, the result goes back into state, and the model decides what to do next. It keeps going until it decides it's finished.

That word, "decides," is doing a lot of work. The model decides based entirely on what you hand it: the available tools, their descriptions, the state, and the instructions. If any of those are subtly wrong, the model makes a locally reasonable choice that is globally a disaster. It won't throw an error. It will just keep choosing, and the choices will look almost right while being completely wrong.

What Actually Happened

The agent that answered ten times (Issue #121)

A user asked a normal question. The agent streamed a campaign card. Then it streamed the same card again. And again. Ten-plus times, identical, until a recursion limit killed the turn. From the outside it looked like the model had lost its mind.

The model was fine. The bug was one line in our state definition.

Our agent can enable extra tools mid-conversation when it realizes it needs them. The list of active tools lived in a field declared like this:

# BEFORE: every update appends to the list instead of replacing it
selected_tools: Annotated[list[str], operator.add]

That operator.add is a reducer. In LangGraph, it tells the framework how to merge updates to a field. operator.add on a list means "append." So every time the agent enabled tools and re-entered the agent node, the tool list didn't get replaced. It grew. Each pass through the loop, the model was handed a longer, noisier, more redundant tool list than the pass before.

A confused agent does the safe thing. Its safe thing was to call get_campaigns and stream a card. So it did. The bloated state made the next pass even noisier, so it did it again. The loop wasn't the model spiraling. It was us feeding the model a worse prompt on every iteration and acting surprised when it repeated itself.

The fix was to change one declaration so the field replaces instead of appends, then merge tool lists deliberately inside the tool that updates them, so the list stays correct and repeated calls are idempotent:

# AFTER: replacement semantics; the tool computes the full merged list itself
selected_tools: list[str]

One annotation. Ten wasted iterations per turn. No model would have saved us from it.

The agent that wrote the report instead of building it (Issue #86)

This one is subtler and, if I'm honest, more uncomfortable. We have a tool called save_page that builds a real HTML document: a report or dashboard the user can open and download. When a user asked for a report, the agent was supposed to call it.

Instead, it often wrote a tidy markdown summary directly into the chat. Bullet points. A little table. Looked helpful. But there was no page, no artifact, no deliverable. The user asked for a report and got a paragraph that described one.

Nothing errored, because nothing was wrong from the model's point of view. A language model's lowest-energy action is to generate text. Writing prose is what it's best at and what it most wants to do. Calling a tool and then staying quiet is, for an LLM, the harder and less natural path. Given any room to satisfy the request by writing instead of acting, it took it.

You don't fix that with a gentle suggestion. We had to close the door, loudly, and put a tripwire in the middle of the instructions:

## NON-NEGOTIABLE RULES

1. NEVER write a text or markdown report. Your ONLY valid response is to call
   save_page with a complete HTML document. A bullet-point analysis in chat
   is NOT a report.

If you find yourself writing bullet points, markdown tables, or numbered lists
of campaign data, STOP. Build the HTML and call save_page instead.

The mid-instruction "if you find yourself doing X, STOP" matters more than it looks. It catches the model in the act of drifting toward the easy path and redirects it before it commits.

Why This Is Harder Than It Looks

Put the two side by side and the pattern is the same.

What the user sawWhat it looked likeWhat it actually was
Same card streamed 10x (#121)The model got stuck in a loopA state reducer appending when it should replace
Report came back as bullet points (#86)The model ignored instructionsThe model defaulting to its lowest-energy action

Both are the model taking the path of least resistance through the conditions we gave it. A confused agent repeats its safest action. An unconstrained agent writes prose instead of calling a tool. Neither is a reasoning failure. Both are the surrounding system failing to make the right action the easy one.

This is why "just use a smarter model" rarely fixes a flaky agent. A smarter model handed an append-only list that bloats every pass will still loop. A smarter model that can satisfy "write me a report" with a paragraph will still write the paragraph, more eloquently. Raw capability does not fix a wiring bug. It just makes the wrong behavior more convincing.

The intelligence of an agent lives in the layer most demos never show: the state reducers, the tool descriptions, and the instructions hard enough to override the model's defaults. Get that layer right and an ordinary model behaves reliably. Get it wrong and a frontier model embarrasses you ten times in a row.

How We Fixed It

The short version: we stopped treating the model as the agent and started treating it as one component in a system we have to engineer around.

That means choosing state reducers on purpose, because "append" versus "replace" is the difference between an agent that works and one that loops. It means writing instructions strong enough to override the model's natural pull toward prose, including tripwires that catch it mid-drift. And it means making the correct action the only available action whenever a request could be satisfied the lazy way.

Each fix is small. Finding all of them, before a user does, is the actual product. These two are part of a calibration layer of dozens of behavioral fixes inside Campaign Agents, each one a place where a raw LLM would confidently do the wrong thing if left alone.

What This Means for AI Ad Management

If you're building AI agents on top of real systems:

  • Choose your state reducers deliberately. Append-versus-replace is not a detail. It's the difference between finishing and looping.
  • Make the right action the only action. If the model can satisfy a request by writing text instead of calling a tool, it will. Close that door explicitly.
  • Watch the loop, not just the answer. A correct final answer can hide an agent that took ten passes and burned a recursion budget to get there.
  • A smarter model is not a fix. Capability makes bad wiring harder to notice, not less harmful.

If you're evaluating AI ad tools, ask the unglamorous question: "When the agent does the wrong thing, is it because the model is weak, or because the system around it is?" The honest builders will tell you it's almost always the system. The good ones have already spent months fixing it.

Frequently Asked Questions

Why does my AI agent get stuck in a loop?
Agent loops usually come from state that accumulates instead of replacing. If each pass through the agent appends to a list it reads back, the context grows on every iteration until the agent falls back to a safe default action and repeats it until a recursion limit stops it. The model is not malfunctioning. The wiring around it is handing it a worse prompt every time.
Why does my AI agent ignore its tools and just write text?
Language models default to the thing they are best at, which is generating prose. If a request can be satisfied by writing a paragraph instead of calling a tool, the model will often take that path even when a tool exists for the job. The fix is to make the tool call the only acceptable response in your instructions, and to forbid the text shortcut explicitly.
Are AI agent failures the model's fault or the engineering's?
Most are the engineering's. The model chooses its next action from the tools, state, and instructions you give it. A loop caused by an append-only state field or a skipped tool caused by weak instructions will happen on any model, including frontier ones. Reliability comes from fixing the surrounding system, not from swapping in something smarter.

Keep reading