Home / Articles

Seven Agent Loop Patterns and When Each One Fails: ReAct, Maker-Checker and More

A practical catalog of agent loop designs, from plan-act-verify to human escalation, with guidance on when each fits, when to avoid it, and how to choose.

Publisher: REACTAPP.TOP9 min read1958 words

This article is published in English.

Sources: Agentic Loops & Design Patterns

Nobody should judge an agent by how clever it sounds. What counts is behavior when traffic is heavy, when something breaks and when an auditor comes asking. That is the ground where loop design patterns outweigh any single prompt. Loops work best when handled as architectural primitives: each one addresses a particular class of problem, carries known trade-offs and ought to be chosen on purpose. The patterns below come with Claude-flavored examples and with the situations in which each one should be avoided.

1. Plan, act, verify

This is the foundational pattern. The agent plans one small step, takes one action, and then checks whether that action brought it closer to a clearly defined goal. It strikes a balance between flexibility and control.

// Pseudo-code: Node/TypeScript orchestrating Claude as an agent

async function planActVerifyLoop(ticket) {
  let iteration = 0;
  const maxIterations = 5;

  while (iteration < maxIterations) {
    iteration++;

    // 1. PLAN: ask Claude for the next step
    const plan = await claude.chat({
      model: "claude-3-opus",
      messages: [
        {
          role: "user",
          content: `You are a coding agent working on ticket ${ticket.id}.
Goal: Make tests pass for this ticket without changing public APIs.

Current context:
${ticket.description}
${ticket.latestFailureLog}

What is the single most useful next action?`
        }
      ]
    });

    // 2. ACT: execute the suggested action if it's in the allowed action space
    const action = parseAction(plan);
    const result = await executeAction(action); // run tests, edit file, etc.

    // 3. VERIFY: use tests as verification
    const verification = await runTests(ticket.testSuite);

    if (verification.allPassing) {
      return { status: "done", iterations: iteration };
    }

    // Attach the failure output back into ticket context
    ticket.latestFailureLog = verification.failureOutput;
  }

  return { status: "budget_exhausted" };
}

In the sketch, Claude handles the micro-planning of the next action, while the loop itself owns verification through tests and enforces an iteration cap. That division prevents the system from announcing success just because the model feels confident.

Good fits are multi-step engineering tasks with measurable progress, rollout scripts and configuration changes that have to settle into a verified working state, and internal tools where strong checks such as tests or schema validation can be built in.

Poor fits include purely creative work like marketing copy or long-form writing, subjective decisions where verification would only stand in for taste, and real-time flows that can't afford several iterations on the critical path.

2. ReAct (reason, then act)

ReAct interleaves short bursts of thought with single actions. Rather than charting the full route up front, the agent studies the newest state, selects one tool, uses it, and then thinks again. That makes it a natural choice for exploratory or diagnostic work, where each fresh observation can legitimately reshape the plan.

# Pseudo-code: Python coordinator orchestrating Claude tool calls

def react_loop(incident):
    iteration = 0
    max_iterations = 6

    while iteration < max_iterations:
        iteration += 1

        # REASON: Claude decides what to inspect next
        reasoning = claude.chat(
            model="claude-3-opus",
            messages=[
                {
                    "role": "user",
                    "content": f"""
You are an SRE assistant triaging a payment incident.

Incident summary:
{incident.summary}

Recent metrics:
{incident.latest_metrics}

Logs snippet:
{incident.logs_snippet}

Decide one next diagnostic action from:
- CHECK_METRICS
- CHECK_LOGS
- CHECK_DB_HEALTH
- SUMMARIZE_FINDINGS_AND_RECOMMEND_ACTION

Explain your reasoning briefly and output JSON with 'action' and 'target'.
"""
                }
            ]
        )

        action = parse_json(reasoning)
        if action["action"] == "SUMMARIZE_FINDINGS_AND_RECOMMEND_ACTION":
            return claude.chat(... )  # final summary + recommended steps

        # ACT: run the selected diagnostic
        observation = run_diagnostic(action, incident)

        # Update incident state for the next reasoning step
        incident.update_with_observation(observation)

Each diagnostic step stays purposeful, and Claude sees fresh context after every action.

Use it for incident diagnosis and root cause analysis, for complex support workflows where each answer changes what to inspect next, and for security investigations in which evidence arrives piecemeal and incomplete.

Skip it when the plan is stable and the pipeline obvious, as in ETL-style document processing, when throughput is high enough that per-step reasoning gets too costly, or when the action space is so narrow that extra deliberation adds nothing.

3. Reflect and revise

Here two phases, generation and critique, are followed by a revision step. It makes a sensible default for anything that needs review before it reaches a customer, a regulator or a production system. The important point is separation: the critic can be another prompt, a cheaper model, or even a rules engine.

async function reflectReviseLoop(draftInput: string) {
  // 1. GENERATE
  const draft = await claude.chat({
    model: "claude-3-opus",
    messages: [
      {
        role: "user",
        content: `
Write a customer-facing email explaining a declined payment due to suspected fraud.
Constraints:
- empathetic but clear
- no admission of fault
- no promises about future approvals

Context:
${draftInput}
`
      }
    ]
  });

  // 2. CRITIQUE (using a cheaper model as checker)
  const critique = await claude.chat({
    model: "claude-3-haiku",
    messages: [
      {
        role: "user",
        content: `
You are a compliance checker.

Review the following email for:
- policy violations
- misleading statements
- over-commitments

Output a JSON with:
- issues: list of strings
- safe: boolean

Email:
${draft.content}
`
      }
    ]
  });

  const review = JSON.parse(critique.content);

  if (review.safe) {
    return draft.content;
  }

  // 3. REVISE
  const revised = await claude.chat({
    model: "claude-3-opus",
    messages: [
      {
        role: "user",
        content: `
You wrote this email:
${draft.content}

Compliance issues:
${review.issues.join("\n")}

Rewrite the email to resolve all issues while preserving intent.
`
      }
    ]
  });

  return revised.content;
}

This pattern suits customer messages on sensitive subjects such as fraud, chargebacks or regulatory change, internal postmortems that may be audited, and any content where policy compliance outranks style.

It doesn't suit low-risk internal brainstorming, where critique adds friction and no real safety, very short chat answers where a second pass is overkill, or latency-critical flows like in-transaction messaging where a single shot has to do.

4. Draft, test, fix

Engineering teams often favor this loop, since the outcome is objective and lives outside the model: the test suite goes green or it stays red. Each round the agent proposes a change, runs the tests and reads what failed. It then patches the problems and repeats, stopping once the system is in a known good state or an iteration ceiling is reached.

async function draftTestFixLoop(issue: Issue) {
  const maxIterations = 4;
  let iteration = 0;

  while (iteration < maxIterations) {
    iteration++;

    // DRAFT: Claude proposes code changes
    const patch = await claude.chat({
      model: "claude-3-opus",
      messages: [
        {
          role: "user",
          content: `
You are an autonomous coding agent.

Ticket:
${issue.title}
${issue.body}

Current failing tests:
${issue.failingTests}

Propose a minimal patch as a diff that makes tests pass
without changing public APIs.`
        }
      ]
    });

    applyPatchToGitRepo(patch.content);

    // TEST: run CI locally or via API
    const testResult = await runCi(issue.branchName);

    if (testResult.success) {
      // FIX_DONE: open a PR with the diff
      await openPullRequest(issue, patch.content);
      return { status: "done" };
    }

    // FEEDBACK: update failingTests for next iteration
    issue.failingTests = testResult.failureSummary;
  }

  return { status: "needs_human_review" };
}

It works on low-risk, well-contained tickets labeled as ready for an agent, on internal tooling where rollback is trivial, and on non-critical paths that can tolerate the latency and cost.

Avoid it for any change that moves money directly through core transaction paths, for schema changes, migrations and cross-cutting refactors, and for regulated logic where an automated edit could break policy.

5. Critic and builder

One agent builds and another checks. In payments this resembles the maker-checker control already used for high-risk operations, which makes it an easy case to put to risk and compliance teams.

It fits anywhere a second pair of eyes is already expected, automation touching production-adjacent systems such as configuration, routing or notifications, and high-stakes reports or dashboards read by leadership.

It doesn't fit trivial tasks where extra review costs more than it protects, latency-sensitive processes that must answer in one step, or pipelines where weak rules would force the checker to wave through anything that looks good enough.

6. Retry with memory

Most naive loops simply try again. A retry-with-memory loop records the failure states and uses them to avoid repeating the same mistake.

def retry_with_memory_loop(task, max_attempts=3):
    failures = []

    for attempt in range(1, max_attempts + 1):
        # Ask Claude to consider past failures before deciding the next move
        decision = claude.chat(
            model="claude-3-opus",
            messages=[
                {
                    "role": "user",
                    "content": f"""
You are handling a task with a flaky external API.

Task:
{task.description}

Past failures:
{failures}

Decide whether to:
- RETRY_API
- FALLBACK_TO_CACHE
- ESCALATE_TO_HUMAN

Explain briefly and output JSON: {{ "choice": "...", "reason": "..." }}
"""
                }
            ]
        )

        choice = parse_json(decision)

        if choice["choice"] == "RETRY_API":
            result = call_api(task)
        elif choice["choice"] == "FALLBACK_TO_CACHE":
            result = use_cache(task)
        else:
            return {"status": "escalated", "failures": failures}

        if result.success:
            return {"status": "success", "attempts": attempt}

        failures.append(result.error_summary)

    return {"status": "max_attempts_exhausted", "failures": failures}

Suitable targets are flaky integrations, rate-limited APIs and external systems outside your control, document parsing with inconsistent formatting, and non-critical workflows where retries are acceptable.

Unsuitable ones are core transaction flows where a retry may duplicate effects, operations that aren't idempotent so a retry isn't known to be safe, and scenarios where failure must be immediate and visible and not silently retried.

7. Human escalation

This is the guardrail pattern for anything that touches real money, real customers or regulatory exposure. The loop runs on its own up to a point, then hands off cleanly once confidence falls or risk rises.

async function triageLoop(ticket: Ticket) {
  const autoActions = ["LABEL", "ROUTE_TO_QUEUE", "REQUEST_MORE_INFO"];

  const decision = await claude.chat({
    model: "claude-3-opus",
    messages: [
      {
        role: "user",
        content: `
You are a support triage agent in a payments company.

Ticket:
${ticket.body}

Decide one of:
- LABEL (low risk)
- ROUTE_TO_QUEUE (medium risk)
- ESCALATE_TO_HUMAN (high risk / unclear)

Return JSON with:
- choice
- risk_level
- rationale
`
      }
    ]
  });

  const choice = JSON.parse(decision.content);

  if (choice.choice === "ESCALATE_TO_HUMAN") {
    await createHumanTask(ticket, choice.rationale);
    return { status: "escalated" };
  }

  // LABEL or ROUTE_TO_QUEUE are automated but bounded
  await applyAutomatedTriage(ticket, choice);
  return { status: "auto_treated" };
}

Use it for any automation that can shape customer experience or perceived fairness, for flows where partial automation helps but full autonomy is risky, and for early deployments that need to learn before controls loosen.

Leave it out of fully internal, low-risk tasks where humans would merely add noise, back-office batch work where human review can't scale, and situations where escalation would swamp people without improving outcomes.

Choosing in practice

When a new agent opportunity appears, "how do I prompt this?" is the wrong first question. Four better ones are:

  1. What signal verifies the result: tests, schemas, rules, metrics or human judgment?
  2. How many steps does the task realistically take?
  3. What happens when the system fails?
  4. Who is affected if it gets things wrong?

The answers point toward a pattern:

  • Strong, binary verification suggests draft, test, fix.
  • An exploratory path suggests ReAct.
  • Concerns about policy and tone suggest reflect and revise, or critic and builder.
  • A flaky environment calls for retry with memory.
  • High risk means building in human escalation from the start.

Loop engineering turns out to be less about clever orchestration and more about disciplined thinking about systems.