Agents From First Principles 05: AI Agent Gets Stuck in a Loop? Add State, Feedback and Stopping Conditions

Page content

An AI agent that keeps calling the same tool, revisiting the same page, rewriting the same file, or repeatedly saying “I’ll try again” is not displaying persistence.

It is displaying a control-flow bug.

This is one of the most common failure modes in agent software because the basic loop is deceptively simple:

observe
decide
act
observe
repeat

The problem is hidden inside the final word.

Repeat until when?

A useful agent needs more than a loop. It needs:

state
feedback
progress signals
budgets
stopping conditions
termination reasons

Without those, an agent can continue making perfectly valid decisions that collectively go nowhere.

This post builds loop control from first principles and then maps it to the software systems where it matters most: coding agents, browser agents, support automation, research agents, data workflows, and long-running task runners.

The core idea is simple:

An agent should not merely know what it did. It should know whether what it did changed the situation in a useful way.


Where This Fits in the Series

We have built the agent stack progressively:

00  agent loop
01  structured actions + validation
02  Best-of-N generation + ranking
03  critique + revision
04  planning + execution
05  state + feedback + stopping

The previous post made plans explicit.

Now we make progress explicit.

Planning answers:

What should happen next?

Loop control answers:

Is what is happening actually moving us toward completion?

The Smallest Broken Agent

Consider this agent:

while True:
    action = model(observation)
    observation = execute(action)

This contains no definition of success.

It contains no definition of failure.

It contains no budget.

It contains no idea of whether two observations are meaningfully different.

The model is therefore asked to solve a problem the runtime should own:

Should I keep going?

That is dangerous because continuation is not only a reasoning question.

It is also a systems question.

The runtime knows things the model may not know reliably:

  • how many steps have executed,
  • how many model calls have been made,
  • how much money has been spent,
  • whether the same action has repeated,
  • whether state changed,
  • whether a tool failed,
  • whether the goal verifier passed,
  • whether a time budget expired.

Those are deterministic signals.

They belong in code.


Search Problem: Why Does My AI Agent Keep Calling the Same Tool?

A common loop looks like this:

search("PyTorch DataLoader deadlock")
read results
search("PyTorch DataLoader deadlock")
read results
search("PyTorch DataLoader deadlock")

Every individual tool call is valid.

The failure is temporal.

The agent is not recognizing that it has already performed the action and obtained no new useful information.

The first fix is explicit action history.

history = []

while True:
    action = model(observation)

    if action in history:
        raise RuntimeError("Repeated action detected")

    history.append(action)
    observation = execute(action)

That is crude, but it reveals the mechanism.

An agent needs memory of its own trajectory.


State Is More Than Conversation History

When developers first add state, they often store only messages:

messages = [
    {"role": "user", "content": task},
    {"role": "assistant", "content": response},
]

That is useful, but agent state usually needs to be more structured.

For example:

from dataclasses import dataclass, field

@dataclass
class AgentState:
    goal: str
    step: int = 0
    observations: list[str] = field(default_factory=list)
    actions: list[dict] = field(default_factory=list)
    completed_items: set[str] = field(default_factory=set)
    failures: list[str] = field(default_factory=list)
    status: str = "running"

Now the runtime can reason deterministically about the trajectory.

conversation history
agent state

Conversation history is mostly model context.

Agent state is runtime truth.


Search Problem: Why Does My Agent Repeat the Same Failed Action?

Suppose a coding agent tries:

run_tests

and receives:

ImportError: missing dependency X

If the state presented to the next model call says only:

Tests failed.

then repeating run_tests is not unreasonable.

The observation needs to contain enough information for adaptation.

observation = {
    "action": "run_tests",
    "success": False,
    "error_type": "ImportError",
    "error": "No module named X",
    "changed_state": False,
}

This gives us a general rule:

Feedback should describe what happened, not merely whether something happened.


Action → Observation Is a Contract

An action should produce a structured observation.

@dataclass
class Observation:
    success: bool
    summary: str
    changed_state: bool
    error_code: str | None = None
    artifacts: list[str] = field(default_factory=list)

Then:

observation = executor.execute(action)

The model may receive a textual rendering of this structure.

But the runtime can inspect it directly.

That enables deterministic loop control.


Progress Must Be Measured

A productive agent loop should change something relevant.

For a coding agent, progress might mean:

fewer failing tests

For a browser agent:

reached new page
completed form field
submitted transaction

For a research agent:

new source discovered
new claim verified
coverage increased

For a support agent:

required customer data collected
issue classified
known remediation completed

For a data pipeline:

more rows processed
schema validated
output artifact produced

This is why generic loop controls are not enough.

Every application needs a notion of useful state transition.


A Simple Progress Function

We can make that explicit.

def progress(previous_state, current_state) -> float:
    score = 0.0

    if current_state.completed_items > previous_state.completed_items:
        score += 1.0

    if len(current_state.failures) < len(previous_state.failures):
        score += 1.0

    return score

Real systems will use domain-specific signals.

The important thing is architectural:

state_t
action_t
state_t+1
progress(state_t, state_t+1)

Now the runtime can ask:

Did anything improve?

without asking the model to narrate whether it believes it improved.


Search Problem: AI Agent Stuck in Infinite Loop

The first defense is a hard step limit.

MAX_STEPS = 20

for step in range(MAX_STEPS):
    ...

This is not sophisticated.

It is still essential.

Every agent loop should have a deterministic outer bound unless the system is explicitly designed as a daemon or persistent service.

Even then, each task episode should usually have its own budget.

A step limit protects against:

  • repeated actions,
  • parser recovery loops,
  • tool failures,
  • bad replanning,
  • evaluator disagreement,
  • model indecision,
  • hidden state bugs.

But a maximum step count only stops the failure.

It does not detect it early.


Add a No-Progress Window

Suppose we track a progress score after every action.

progress_history = []

Then:

NO_PROGRESS_LIMIT = 3

if len(progress_history) >= NO_PROGRESS_LIMIT:
    recent = progress_history[-NO_PROGRESS_LIMIT:]

    if all(score <= 0 for score in recent):
        stop("no_progress")

Now the system can terminate after three useless steps instead of twenty.

This matters enormously for expensive models and tools.


Detect Exact Repetition

A simple fingerprint can detect identical actions.

import json
import hashlib


def fingerprint(action: dict) -> str:
    payload = json.dumps(action, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()

Then:

seen_actions = set()

fp = fingerprint(action)

if fp in seen_actions:
    stop("repeated_action")

seen_actions.add(fp)

This catches:

search(query=X)
search(query=X)

but not:

search(query=X)
search(query=X with slightly different wording)

That requires semantic repetition detection, which belongs later in the sophistication ladder.

Start exact.

Measure how much it catches.

Only then add complexity.


Repetition Is Sometimes Correct

Be careful.

Repeated actions are not always loops.

Examples:

poll_build_status
poll_build_status
poll_build_status

may be valid.

Likewise:

scroll_down
scroll_down
scroll_down

may be necessary in browser automation.

So action definitions can encode repetition policy.

TOOL_POLICY = {
    "search": {"repeatable": False},
    "poll_build_status": {"repeatable": True},
    "scroll_down": {"repeatable": True},
}

This is another recurring principle:

Agent runtime policy should reflect the semantics of the software application.


Detect Cycles, Not Only Repetition

A more subtle loop is:

A → B → A → B → A → B

For example:

open_settings
change_setting
open_settings
change_setting

or:

edit_file
run_tests
edit_file
run_tests

The second example may be productive.

The first may not be.

Again we need progress.

A cycle alone is not enough.

A cycle with no improvement is much more informative.

if repeated_cycle(history) and no_progress(progress_history):
    stop("nonproductive_cycle")

Application: Coding Agents

Coding agents are one of the clearest places to apply this pattern.

A typical loop is:

inspect code
edit
run tests
inspect failure
edit
run tests

This is intentionally cyclic.

The loop becomes pathological when the objective metrics do not improve.

Useful progress signals include:

number of failing tests
compiler error count
lint error count
type-check error count
changed failing-test identities
coverage delta
benchmark delta

A coding-agent progress function might be:

def coding_progress(before, after):
    return before.failing_tests - after.failing_tests

Then:

12 failures → 8 failures → 3 failures → 0

is clearly productive.

But:

12 → 12 → 12 → 12

should trigger a strategy change or stop.

This is much stronger than asking:

Do you think your fix worked?

Application: Browser Agents

Browser agents often fail through navigational cycles.

Example:

homepage
login
homepage
login

Or:

search results
product page
back
search results
same product page

Useful state includes:

BrowserState(
    url=current_url,
    page_hash=dom_hash,
    completed_fields={...},
    submitted=False,
)

Progress signals might be:

  • new URL reached,
  • new form field completed,
  • required data extracted,
  • cart changed,
  • submission confirmation observed.

A browser agent should not rely solely on visual novelty.

A page may visually change without meaningful task progress.


Application: Research Agents

Research agents have a different loop problem.

They often keep searching indefinitely because there is always another query.

search
read
extract
search again

What constitutes completion?

Possible criteria:

all required claims sourced
minimum independent source count reached
contradictions resolved
coverage threshold reached
budget exhausted

A useful research state might track:

@dataclass
class ResearchState:
    claims_required: set[str]
    claims_supported: set[str]
    sources_seen: set[str]
    contradictions: list[str]

Then progress is not “found another webpage.”

Progress is something closer to:

coverage increased

Application: Customer Support Agents

Support agents can get trapped in conversational loops:

Can you provide your order number?

User provides order number.

Can you provide your order number?

That is usually a state-update failure.

The value was present in conversation text but never promoted into structured state.

state.order_number = extracted_order_number

Then the runtime can enforce:

if state.order_number:
    disable_action("ask_for_order_number")

This is a powerful design pattern:

Once a prerequisite is satisfied, remove actions whose purpose was to satisfy it.

Action-space reduction is a form of loop prevention.


Application: Data and ETL Agents

Suppose an agent manages a data-cleaning task.

inspect schema
repair data
validate
repair again

Useful progress signals might include:

invalid rows decreasing
schema violations decreasing
missing-value rate decreasing
output artifact produced

If the agent repeatedly transforms the dataset but validation does not improve, the runtime should stop or escalate.

This applies to:

  • data ingestion,
  • schema migration,
  • automated cleanup,
  • reconciliation systems,
  • report generation.

Application: Long-Running Task Runners

Not every agent is interactive.

Some run for minutes or hours.

These systems need more than max_steps.

They often need multiple budgets:

@dataclass
class Budget:
    max_steps: int
    max_model_calls: int
    max_tool_calls: int
    max_seconds: float
    max_cost: float

Then continuation becomes a runtime policy.

if budget.exhausted():
    stop("budget_exhausted")

This is especially useful for:

  • repository analysis,
  • autonomous debugging,
  • batch research,
  • code migration,
  • dataset processing,
  • automated QA.

Stopping Conditions Should Be Explicit

A serious agent should stop for named reasons.

For example:

class StopReason:
    SUCCESS = "success"
    MAX_STEPS = "max_steps"
    NO_PROGRESS = "no_progress"
    REPEATED_ACTION = "repeated_action"
    NONPRODUCTIVE_CYCLE = "nonproductive_cycle"
    TOOL_FAILURE = "tool_failure"
    BUDGET_EXHAUSTED = "budget_exhausted"
    USER_INPUT_REQUIRED = "user_input_required"

This matters operationally.

Compare:

agent stopped

with:

agent stopped: no_progress after 3 consecutive non-improving steps

The second is debuggable.


Success Must Be Verified

The most important stopping condition is success.

But success should preferably come from an external verifier.

For a coding agent:

success = tests_pass()

For a browser agent:

success = confirmation_page_visible()

For a data agent:

success = schema_valid(output)

For a research agent:

success = required_claims <= supported_claims

The weakest version is:

success = model_says_done

Use that only when no stronger signal exists.


A Complete Minimal Loop Controller

Here is a small standalone implementation.

from dataclasses import dataclass, field
from typing import Callable, Any
import time


@dataclass
class LoopState:
    step: int = 0
    action_history: list[dict] = field(default_factory=list)
    progress_history: list[float] = field(default_factory=list)
    observations: list[Any] = field(default_factory=list)
    stop_reason: str | None = None


@dataclass
class LoopBudget:
    max_steps: int = 12
    max_seconds: float = 60.0
    no_progress_limit: int = 3


class AgentLoop:
    def __init__(
        self,
        decide: Callable,
        execute: Callable,
        measure_progress: Callable,
        verify_success: Callable,
        budget: LoopBudget,
    ):
        self.decide = decide
        self.execute = execute
        self.measure_progress = measure_progress
        self.verify_success = verify_success
        self.budget = budget

    def run(self, initial_observation):
        state = LoopState(observations=[initial_observation])
        started = time.monotonic()

        while True:
            if self.verify_success(state):
                state.stop_reason = "success"
                return state

            if state.step >= self.budget.max_steps:
                state.stop_reason = "max_steps"
                return state

            if time.monotonic() - started >= self.budget.max_seconds:
                state.stop_reason = "time_budget"
                return state

            action = self.decide(state)

            if action in state.action_history:
                state.stop_reason = "repeated_action"
                return state

            before = state.observations[-1]
            after = self.execute(action)

            state.action_history.append(action)
            state.observations.append(after)
            state.step += 1

            progress = self.measure_progress(before, after)
            state.progress_history.append(progress)

            limit = self.budget.no_progress_limit
            if len(state.progress_history) >= limit:
                recent = state.progress_history[-limit:]
                if all(value <= 0 for value in recent):
                    state.stop_reason = "no_progress"
                    return state

This is already dramatically safer than:

while True:
    action = model(...)
    execute(action)

But Should We Stop or Recover?

Detecting a loop does not always mean giving up.

We have three broad responses:

stop
change strategy
escalate

For example:

if reason == "no_progress":
    strategy = "replan"

or:

if reason == "repeated_action":
    strategy = "ask_for_alternative"

or:

if reason == "tool_failure":
    strategy = "fallback_tool"

This turns loop detection into feedback.

That gives us:

act
observe
measure progress
productive?
 ├─ yes → continue
 └─ no
   diagnose
 stop / change strategy / escalate

That is the beginning of adaptive control.


Do Not Let the Recovery Path Become Another Loop

A common mistake is:

main loop stuck
replan
main loop stuck
replan
main loop stuck

So recovery actions need budgets too.

MAX_REPLANS = 2
MAX_TOOL_RETRIES = 2
MAX_CRITIQUE_PASSES = 1

Nested loops need nested limits.


Search Problem: My Agent Never Stops Even After Finishing

This usually means the runtime has no independent completion signal.

The model completes the task, but the loop asks:

What next?

So the model invents another action.

The fix is not necessarily a better prompt.

It is often an explicit verifier.

if goal_verifier(state):
    return result

Examples:

all tests pass
required file exists
transaction confirmation received
all requested fields populated
all target claims sourced

Search Problem: My Agent Stops Too Early

The opposite failure happens when the model emits something like:

{"action": "finish"}

before the task is actually complete.

The runtime should validate finish actions too.

if action["name"] == "finish":
    if verify_success(state):
        stop("success")
    else:
        reject_action("goal_not_complete")

This preserves the distinction established earlier in the series:

The model proposes. The runtime validates.


Search Problem: My Agent Burns Too Many Tokens

Loop control is also cost control.

Suppose each step consumes roughly:

8,000 input tokens
2,000 output tokens

A twenty-step runaway loop is not just slow.

It can be extremely expensive.

Useful telemetry includes:

model_calls
input_tokens
output_tokens
tool_calls
wall_clock_seconds
cost
steps_to_success

Then benchmark:

cost per successful task

not simply:

average cost per run

A cheaper run that fails is not necessarily cheaper software.


Application Matrix

Here is how this pattern maps into real systems.

Software Agent loop Useful progress signal Typical stop condition
Coding agent edit → test → inspect failing tests decrease tests pass / no progress
Browser agent navigate → interact → observe required page/form state changes confirmation reached
Research agent search → read → extract claim coverage increases coverage target met
Support agent ask → classify → act required case fields resolved issue resolved / escalation
Data agent inspect → transform → validate validation errors decrease dataset valid
CI remediation inspect logs → patch → rerun failing checks decrease CI green
Migration agent inspect → transform → verify migrated units increase all units migrated
QA agent test → diagnose → retest defect count decreases quality threshold reached

The loop structure is similar.

The meaning of progress is application-specific.

That is the important engineering lesson.


A Better Agent State Machine

Instead of thinking only in terms of prompts, think in states.

RUNNING
ACTION_SELECTED
ACTION_EXECUTED
OBSERVED
PROGRESS_CHECKED
  ├→ SUCCESS
  ├→ CONTINUE
  ├→ RECOVER
  └→ STOPPED

This is ordinary software engineering.

That is exactly why it helps.

Agent systems become easier to reason about when we stop pretending every control decision belongs inside natural language.


Telemetry You Should Record

At minimum:

trace = {
    "step": step,
    "action": action,
    "observation": observation,
    "progress": progress,
    "elapsed_ms": elapsed_ms,
    "stop_reason": stop_reason,
}

For production systems also consider:

action fingerprint
state fingerprint
model name
prompt version
tool version
retry count
replan count
token usage
cost
error category

Without trajectory telemetry, an agent loop failure often looks like:

The AI did something weird.

With telemetry it becomes:

Step 7 repeated the same search action as Step 4 after three zero-progress observations.

That is a software bug you can work with.


How to Test Loop Control

Do not wait for production loops.

Inject them deliberately.

Test 1: repeated action

Force the policy to return the same action twice.

Expected:

stop_reason = repeated_action

Test 2: zero progress

Return valid observations that never improve state.

Expected:

stop_reason = no_progress

Test 3: successful completion

Make the verifier pass on step three.

Expected:

stop_reason = success
steps = 3

Test 4: premature finish

Have the model propose finish before success.

Expected:

finish rejected
loop continues

Test 5: budget exhaustion

Make every action unique but useless.

Expected:

stop_reason = max_steps

Benchmark the Mechanism

Compare:

A: no loop controls
B: max-step only
C: max-step + repetition detection
D: max-step + repetition + progress detection
E: D + adaptive recovery

Measure:

verified success rate
average steps
steps on failed tasks
model calls
latency
tool calls
cost per successful task
false-positive stops
false-negative loops

The last two matter.

A loop detector that constantly stops productive work is not useful.


Failure Mode: Progress Metric Is Wrong

Suppose a coding agent measures:

number of changed files

Then it can appear productive by editing more files.

That metric is badly aligned.

Likewise a research agent measuring:

number of URLs visited

can improve forever without answering the question.

The progress metric should move toward the goal, not toward activity.

activity ≠ progress

This distinction is fundamental to agent engineering.


Failure Mode: The Model Games the Progress Signal

If the agent can manipulate its own progress metric, it may accidentally optimize the metric rather than the goal.

For example, if success is:

no failing tests

then deleting tests is technically effective.

So verifiers need integrity constraints.

For a coding agent:

tests pass
AND
required tests still exist
AND
expected behavior preserved

For a research agent:

claims sourced
AND
sources independent
AND
citations actually support claims

This moves us toward verification, which will become a dedicated post later in the series.


Failure Mode: Too Much State

Agent state can grow without bound.

step 1 observation
step 2 observation
...
step 500 observation

If all of that is fed back to the model, context becomes noisy and expensive.

So distinguish:

runtime state
model-visible state
long-term memory

The runtime may preserve the entire trace.

The model may only need:

current goal
current plan
recent actions
current failures
important completed work

This is another reason structured state is better than blindly appending messages.


Failure Mode: Retry Is Mistaken for Recovery

Retry means:

do the same thing again

Recovery means:

change something relevant

Examples:

retry same API call after timeout

can be correct.

But:

retry same invalid tool call after schema rejection

usually is not.

A useful recovery changes one of:

action
arguments
tool
plan
model
context
strategy

When This Technique Is Useful

Use explicit loop control when software contains repeated model-driven action.

Especially:

  • coding agents,
  • browser automation,
  • research systems,
  • autonomous debugging,
  • support automation,
  • data repair,
  • workflow remediation,
  • CI repair,
  • migration tools,
  • multi-step tool-use systems.

The more expensive or consequential each action is, the more important deterministic loop control becomes.


When You Do Not Need It

If your system is:

input
one model call
output

then you do not need an agent loop controller.

Likewise, if the process is a known fixed workflow:

A → B → C

ordinary workflow-engine semantics may be enough.

Do not introduce adaptive loops merely to make the architecture look agentic.


The Deeper Pattern

We started the series with:

observe
decide
act
observe

Now we can refine it:

              ┌──────────────────────────┐
              │                          │
              ↓                          │
          observe                        │
              ↓                          │
          update state                   │
              ↓                          │
            decide                       │
              ↓                          │
           validate                      │
              ↓                          │
            execute                      │
              ↓                          │
          observe result                 │
              ↓                          │
       measure progress                  │
              ↓                          │
       verify completion                 │
         /        |        \             │
     success   recover   continue ───────┘

That is much closer to a production agent.

The LLM still matters.

But the reliability comes increasingly from the software around it.


The Rule to Remember

When an agent gets stuck, do not immediately change the prompt.

First ask:

What state changed?
What feedback was produced?
What counted as progress?
What prevented repetition?
What defined success?
What forced termination?

If those questions have no concrete answers, the loop is underspecified.


Next: Agents With Tools

We now have:

structured actions
selection
revision
planning
state
feedback
stopping

The next step is to look more closely at the interface between an agent and the software it can operate.

Because another extremely common failure is:

Why does my AI agent keep choosing the wrong tool?

In the next post we will build tool interfaces from first principles and look at:

  • tool descriptions,
  • schema design,
  • routing,
  • action-space size,
  • ambiguous tools,
  • tool result design,
  • tool errors,
  • permission boundaries,
  • and how the software application itself should constrain what the model is allowed to do.

The agent loop gives us motion.

The tool interface determines where that motion can go.