The Scheduling Agent Proposed the Same Three Meeting Times Until It Hit Its Step Limit. Your Framework's Loop Cap Is Not a Stop Condition.

Every agent framework ships a default iteration cap, and it was chosen by someone who never saw your task. Here is how to wrap the framework's loop with stop conditions that actually describe progress.

A personal scheduling agent was asked to find a 30-minute slot with four people across three time zones. It checked calendars, proposed Tuesday at 10, saw a conflict, proposed Wednesday at 3, saw a conflict, proposed Thursday at 9, saw a conflict, and then proposed Tuesday at 10 again. It kept cycling through the same three slots until the framework's built-in iteration cap ended the run, and the user got back a generic "maximum iterations reached" error after a minute of waiting and a few dozen paid model calls.

Nothing crashed. The framework did exactly what it promised. The problem is that the only thing standing between the agent and an infinite loop was a number someone else picked, for a task they had never seen.

🔍 What the default cap actually measures

Most agent frameworks and SDKs expose some form of max_iterations, max_turns or recursion_limit, with a default value baked in. That default answers one question: how many times may the loop run before we assume something is broken? It is a circuit breaker, set loose enough that legitimate long tasks rarely trip it.

It does not answer the questions you actually care about:

  • Is the agent making progress? Step 18 of a stuck loop and step 18 of a productive search look identical to a counter.
  • Is this run still worth its cost? A cap counts turns, not tokens or tool spend. One turn that reads a 200-page PDF counts the same as one that reads a single row.
  • What should the user get when it stops? Hitting the cap usually raises an exception. Whatever partial work the agent did is gone.

So the cap stays, as a last-resort fuse. But your real stop conditions belong in a thin layer you own, wrapped around whatever loop the framework runs.

🧱 The wrapper: four stop conditions you write yourself

Nearly every framework gives you a hook between steps: a callback, a middleware, an event stream, or simply the ability to drive the loop one step at a time. Use that hook to check four things after every step.

1. Repetition: has it done this exact thing before?

Fingerprint each tool call by its name plus normalised arguments. If the same fingerprint shows up a second time with no new information in between, the agent is circling. For the scheduling agent, propose_slot(tue-10:00) appearing twice is the whole bug, visible on step 4 instead of step 25.

2. Progress: has anything new been learned recently?

Define progress in task terms. For scheduling, it is "the set of ruled-out slots grew." For a research agent, "a new source was added." For a data-cleaning agent, "the count of failing rows dropped." If the progress measure has not moved in N steps, stop. N is small, usually two or three.

3. Budget: is this still worth paying for?

Track tokens and tool costs directly, not turns. Set the ceiling from what the task is worth. Booking a meeting is worth very little compute; a long migration plan might be worth a lot. That number belongs to you, not to the framework.

4. Goal: is it actually done?

Agents often keep going after they've succeeded, re-checking and re-confirming. A cheap, deterministic check ("does the calendar now contain an event with all four attendees?") ends the run cleanly without asking the model whether it feels finished.

💡 A sketch in framework-neutral pseudocode

def run_with_stops(agent, task, budget_usd, stall_limit=3):
    seen_calls = set()
    stalled_steps = 0
    progress = task.progress_signal()   # e.g. len(ruled_out_slots)

    for step in agent.iter_steps(task):          # framework's loop, one step at a time
        if step.tool_call:
            fp = (step.tool_call.name, normalise(step.tool_call.args))
            if fp in seen_calls:
                return stop("repeated_action", task, detail=fp)
            seen_calls.add(fp)

        new_progress = task.progress_signal()
        stalled_steps = 0 if new_progress != progress else stalled_steps + 1
        progress = new_progress
        if stalled_steps >= stall_limit:
            return stop("no_progress", task)

        if agent.spend_usd() > budget_usd:
            return stop("over_budget", task)

        if task.goal_met():
            return finish(task)

    return stop("framework_cap", task)  # the fuse, reached only if everything above missed

Two details matter more than the code itself.

stop() returns, it does not raise. Each stop produces a structured result: the reason, the partial state, and what was tried. For the scheduling agent that reads as "No slot found. Ruled out: Tue 10:00 (Priya), Wed 15:00 (Tom), Thu 09:00 (Ana). Next free overlap starts Monday." That is a useful answer. An exception is not.

The framework cap is the last line, not the first. Set it comfortably above anything your own conditions would allow. If runs ever end with framework_cap, treat it as a bug in your stop logic, because something slipped past all four checks.

⚠️ Where this goes wrong

  • ❌ Lowering the framework cap and calling it fixed. A cap of 8 hides the loop on the scheduling task and breaks the legitimate 12-step travel booking next door.
  • ❌ Exact-match fingerprints on noisy arguments. If arguments include timestamps or request ids, every call looks new. Normalise before hashing.
  • ❌ A progress signal the model reports about itself. "I am making progress" is not a measurement. Read it from state the tools changed.
  • ✅ Logging the stop reason on every run. Over a week, the distribution of repeated_action versus no_progress versus over_budget tells you which prompt or tool to fix next.

Choosing a framework with this in mind

When comparing agent frameworks, the size of the default iteration cap barely matters. What matters is whether you can get between the steps: step-wise iteration, a per-step callback with access to tool calls and arguments, and a way to end the run that returns state instead of throwing it away. A framework that only offers run(task, max_iterations=…) and gives you back a final string makes all four checks above hard to write. That is worth knowing before you build on it.

The cap tells you when to give up on the framework's behalf. Your stop conditions tell you when the agent has stopped being useful, and only you can define that.

Keep reading

Similar posts

Matched on shared tags and category — the more bars, the stronger the overlap with what you just read.