beginnercoreprinciplesautonomyverificationstate

Core Principles of Loop Engineering

Goal definition, autonomous execution, verification, state persistence, error recovery, and bounded iteration.

Loop Engineering is built on a set of foundational principles derived from real production experience with tools like Claude Code, Aider, LangGraph, and the broader agent ecosystem. These are not abstract ideals — they are concrete design constraints that determine whether an autonomous loop converges on a solution or spirals into failure.

1. Goal Definition with Testable Termination

Every loop must begin with a clear, verifiable goal. This is the most critical design decision because it defines when the loop stops — and a loop without a reliable stopping condition is the single most common and expensive mistake in loop engineering.

A well-defined goal specifies three things:

  • Success state — What does "done" look like? ("All tests in test/auth/ pass and lint is clean.")
  • Constraints — What boundaries should the agent respect? ("Do not modify files outside src/auth/.")
  • Failure conditions — When should the loop give up? ("Escalate after 3 failed attempts on the same test.")

The distinction between good and bad goals is practical, not philosophical. "Improve the code" is a bad goal because the loop never knows when to stop — quality is subjective and the agent will either halt prematurely or burn tokens indefinitely. "Make the CI build green on the payments-refactor branch" is a good goal because success is objectively checkable by a deterministic tool.

Good Goal:  "All tests pass, lint is clean, and no new type errors"
Bad Goal:   "Make the code better"
Bad Goal:   "Fix the bug"                    (which bug? how do you know it's fixed?)

In Claude Code (github.com/anthropics/claude-code), you express goals through the task prompt combined with bounded execution flags. In the open-source Aider tool (github.com/paul-gauthier/aider, 30K+ GitHub stars), goals are expressed through the --message flag combined with the auto-commit loop: Aider writes code, runs your test command, and repeats until the tests pass or it exhausts its attempt budget.

# Claude Code: define a goal with bounded execution
claude -p "Make all 14 tests in pkg/db/access/ pass with clickhouse-client v3" \
  --max-turns 20

# Aider: goal is implicit in the message + test command loop
aider --message "Migrate clickhouse-client to v3" --test-cmd "go test ./pkg/db/access/..."

2. Autonomous Execution

The defining characteristic of Loop Engineering is minimizing human intervention while the loop runs. An autonomous loop should be able to decide what action to take next, execute it using available tools, recover from errors, and know when to ask for help.

This does not mean removing the human entirely. It means the human operates at a higher level of abstraction — setting goals, defining constraints, and reviewing outcomes — rather than prompting each step.

Levels of Autonomy

LevelDescriptionReal-World Example
L0 — DirectedHuman specifies every actionManual API calls via curl or Postman
L1 — AssistedSystem suggests, human approvesGitHub Copilot inline completion in VS Code
L2 — SupervisedSystem acts, human reviewsClaude Code in default prompt-approval mode
L3 — AutonomousSystem acts within defined boundsClaude Code --dangerously-skip-permissions with a bounded task, or Aider's --yes flag
L4 — Fully AutonomousSystem sets its own goalsOpenHands (github.com/All-Hands-AI/OpenHands) solving SWE-bench tasks end-to-end

Most practical Loop Engineering operates at L3 — autonomous within defined boundaries that the human specified. For example, in the CrewAI multi-agent framework (github.com/crewAIInc/crewAI), you define a crew with specific roles and tasks, then call crew.kickoff() — the agents coordinate autonomously within the constraints you set, but they do not decide their own mission.

# CrewAI: agents execute autonomously within defined roles
from crewai import Agent, Task, Crew

researcher = Agent(
    role="Senior Backend Engineer",
    goal="Fix the failing tests in the authentication module",
    backstory="You are an expert at debugging test failures.",
    tools=[read_file_tool, run_tests_tool, edit_file_tool],
)

task = Task(
    description="Make all tests in test/auth/ pass. Do not modify files outside src/auth/.",
    agent=researcher,
    expected_output="All tests passing with exit code 0.",
)

crew = Crew(agents=[researcher], tasks=[task])
result = crew.kickoff()

3. Verification and Feedback

A loop that cannot verify its own output is not a loop — it is a pipeline running blind. The verification component is the sensor in the control-theory analogy, and it is the hardest part of loop engineering to get right, not the model selection.

There are two categories of verification:

Deterministic Verification (preferred)

Tests, type checkers, compilers, linters, and API status codes return an objective pass/fail that the model cannot argue its way around. These are the gold standard because they are fast, reliable, and impartial. LangChain's loop architecture wraps a grader around agent output — something that checks the result against a rubric and sends feedback back if it fails.

In Claude Code, verification happens through tool output: the agent runs pytest, reads the exit code, and decides its next action based on the objective result. In LangGraph (github.com/langchain-ai/langgraph), the same pattern is implemented as a conditional edge — the graph routes the output through a grader node before deciding whether to loop back or terminate.

# LangGraph: deterministic verification as a graph node
from langgraph.graph import StateGraph, END

def verify_node(state):
    """Run tests and return pass/fail as part of graph state."""
    result = subprocess.run(["pytest", "test/auth/"], capture_output=True)
    state["tests_pass"] = result.returncode == 0
    state["test_output"] = result.stdout.decode()
    return state

def route_by_verification(state):
    """Conditional edge: loop back on failure, terminate on success."""
    if state["tests_pass"]:
        return END
    return "fix_node"

graph = StateGraph(AgentState)
graph.add_node("fix_node", fix_node)
graph.add_node("verify_node", verify_node)
graph.add_conditional_edges("fix_node", route_by_verification)

LLM-as-Judge Verification (when deterministic is unavailable)

A second model grades the output against criteria. This is more flexible but can be gamed or can collude with the actor model. Reserve it for genuinely unquantifiable qualities like tone, framing, or writing style. LangGraph's langgraph.evaluation module provides built-in evaluators for this purpose.

The practical advice from teams running loops in production is consistent: put a deterministic check in the cycle wherever one exists, and reserve model judgment for the things that cannot be mechanically checked.

4. State Persistence

Loops operate across multiple iterations, and each iteration depends on what happened before. The model forgets everything between runs, so memory must live outside the conversation — in files, databases, or external boards.

State persistence serves four purposes:

  • Context preservation between iterations without recomputation
  • Progress tracking so the loop knows where it left off
  • Crash recovery if the system is interrupted
  • Debugging by reviewing the full history of what was tried

State in Real Tools

Claude Code persists state through the repository itself — files committed to git are the durable record of what the loop has done. The CLAUDE.md file at the repo root acts as persistent context that survives across sessions. When Claude Code runs a loop with a goal prompt, it can read its own prior git commits to understand what was already attempted.

Aider persists state through its git integration: each code change is auto-committed with a descriptive message, creating a full history that Aider can reference. The .aider.conf.yml file provides additional persistent context that survives across sessions.

# Aider's state persistence through git commits
aider --message "Fix auth middleware" --auto-commits
# Each change is committed automatically:
#   commit 1: "aider: Fixed token validation in auth middleware"
#   commit 2: "aider: Added expiry check to refresh endpoint"
# Aider reads these commits to avoid repeating failed approaches

LangGraph provides langgraph-checkpoint for durable state across graph executions. Checkpoints are saved to SQLite, Postgres, or other backends, so a long-running agent graph can resume exactly where it left off after a crash.

# LangGraph: persistent state via checkpointing
from langgraph.checkpoint.sqlite import SqliteSaver

checkpointer = SqliteSaver.from_conn_string("agent_state.db")
graph = compiled_graph.with_checkpointer(checkpointer)

# Resume a loop from its last checkpoint after a crash
config = {"configurable": {"thread_id": "auth-fix-session-1"}}
for event in graph.stream({"tests_pass": False}, config):
    print(event)

OpenHands (github.com/All-Hands-AI/OpenHands) uses a state management system that tracks the full action history — browser actions, terminal commands, file edits — in a structured state object. When an OpenHands agent works on a SWE-bench task, it can resume from any checkpoint in its history.

State Hierarchy

External State (survives across runs)
├── Task State (current goal, success criteria, progress)
│   ├── Context State (relevant context gathered so far)
│   └── Result State (outputs produced, tests run)
└── System State (configuration, tool availability, budgets)

Internal State (per-iteration, in context window)
├── Current reasoning trace
├── Last action and result
└── Verification outcome

5. Error Recovery and Graceful Degradation

A loop running unattended is also a loop making mistakes unattended. Robust loops distinguish between recoverable errors (a failing test, a type error, an API timeout) and fatal errors (missing credentials, corrupted state, a goal that cannot be achieved).

For recoverable errors, the loop should:

  1. Detect the failure through verification
  2. Diagnose what went wrong (read stderr, inspect logs)
  3. Adjust the approach (do not simply retry the same action)
  4. Try again with the new information

For fatal errors, the loop should:

  1. Recognize the error is unrecoverable
  2. Preserve all state and progress
  3. Escalate to a human with a clear explanation of what was attempted and what failed

Real-World Error Recovery Patterns

Claude Code has built-in error recovery: when a bash command fails, Claude reads the stderr output and adjusts its next command accordingly. If it runs into a permissions error, it may switch to a different approach rather than repeating the same failing command. The --max-turns flag prevents indefinite retry loops.

# Claude Code: bounded execution with built-in error recovery
claude -p "Fix all failing tests in test/auth/" --max-turns 20
# Claude will:
# 1. Run pytest and read the failure output
# 2. Inspect the relevant source files
# 3. Apply a fix
# 4. Re-run tests to verify
# 5. If the fix didn't work, diagnose from stderr and try a different approach
# 6. Stop after 20 turns regardless

Aider implements the same pattern through its --test-cmd loop. If Aider's code change causes a test to fail, it reads the test output, diagnoses the issue, and generates a new fix — all without human intervention when run with --yes.

No-Progress Detection

The signature failure mode of a naive loop is that it repeats the same failing action forever — one of the common production failure scenarios. Robust loops carry no-progress detection: if the last several steps produced the same error or left the state unchanged, the loop breaks and escalates rather than burning tokens circling a dead end.

def has_progress(history, window=3):
    """Check if the last N results differ from each other."""
    recent = history[-window:]
    return len(set(r.output for r in recent)) > 1

# In LangGraph, this is implemented as a conditional edge
def route_on_stuck(state):
    recent_results = state["history"][-3:]
    if len(set(r["result"] for r in recent_results)) == 1:
        state["status"] = "stuck"
        return "escalate_node"
    return "attempt_node"

6. Bounded Iteration

The three essential bounds are:

BoundWhat It LimitsTool Implementation
Max iterationsNumber of loop cyclesClaude Code --max-turns 20; Aider implicit retry budget
Token budgetTotal tokens consumedClaude API max_tokens; Anthropic SDK usage field tracking
Wall-clock timeoutReal time elapsedBash timeout 30m claude ...; OS-level process limits

A production loop typically uses all three bounds in combination:

import time

MAX_ITERATIONS = 20
TOKEN_BUDGET = 100_000
TIMEOUT_SECONDS = 3600  # 1 hour

for step in range(MAX_ITERATIONS):          # Hard iteration cap
    if token_count > TOKEN_BUDGET:          # Budget guard
        raise BudgetExceeded(f"Token budget {TOKEN_BUDGET} exceeded at step {step}")
    if time.time() - start > TIMEOUT_SECONDS:  # Time guard
        raise TimeoutExceeded(f"Timed out after {TIMEOUT_SECONDS}s at step {step}")
    # ... execute loop step

The Anthropic documentation on the Claude API (docs.anthropic.com) recommends setting max_tokens on every API call and monitoring usage through the usage field in the response. For agentic loops using the Anthropic SDK, the stop_reason field in the response indicates whether the model finished naturally (end_turn) or hit the token limit (max_tokens).

# Anthropic SDK: explicit token budget enforcement
import anthropic

client = anthropic.Anthropic()

message = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=4096,
    messages=[{"role": "user", "content": "Fix the failing test in test_auth.py"}],
)

# Check if the model was cut short
if message.stop_reason == "max_tokens":
    print(f"Warning: response truncated at {message.usage.output_tokens} tokens")

In OpenAI Codex CLI (github.com/openai/codex), bounds are enforced through configuration flags. In Cline (github.com/cline/cline), the VS Code plugin uses MCP protocol support to enforce tool-level permissions that act as additional safety bounds on what the agent can do.

7. The Maker/Checker Split

The most useful structural principle in loop engineering is separating the agent that produces output from the agent that verifies it. The model that wrote the code is too generous grading its own homework — a second agent with different instructions catches the patterns the first one talked itself into.

This principle manifests at multiple levels across the real tooling landscape:

  • Sub-agents in Claude Code: When using Claude Code's agentic mode, the implementation agent writes the code, and a separate verification pass checks it. The /review skill (github.com/anthropics/claude-code) invokes a second model to review the diff against the specification.
  • LangGraph evaluator chains: In LangGraph's evaluation framework, a "judge" LLM with a different system prompt grades the output of the "actor" LLM. This is documented in langchain-ai/langgraph under the evaluation module.
  • CrewAI multi-agent crews: Define separate agents with different roles — one "Developer" agent writes code, one "Reviewer" agent checks it. The crew orchestrates them sequentially.
  • AutoGen multi-agent: Microsoft's AutoGen framework (github.com/microsoft/autogen) implements maker/checker through its conversation patterns — a "coder" agent proposes changes and a "critic" agent evaluates them before acceptance.
  • MetaGPT: The multi-agent framework (github.com/geekan/MetaGPT, 45K+ stars) assigns distinct roles — Architect, Project Manager, Engineer, QA Engineer — where the QA Engineer agent specifically reviews code produced by the Engineer agent.
# CrewAI: maker/checker split with separate agents
from crewai import Agent, Task, Crew

developer = Agent(
    role="Developer",
    goal="Implement the authentication fix",
    tools=[bash_tool, file_edit_tool],
)

reviewer = Agent(
    role="Code Reviewer",
    goal="Review the implementation for correctness and adherence to project conventions",
    tools=[bash_tool, file_read_tool],
)

implement_task = Task(
    description="Fix the failing tests in test/auth/. Do not modify files outside src/auth/.",
    agent=developer,
)

review_task = Task(
    description="Review the changes made by the Developer. Run tests and lint. "
                "Reject if any test fails or lint errors exist.",
    agent=reviewer,
)

crew = Crew(agents=[developer, reviewer], tasks=[implement_task, review_task])
result = crew.kickoff()

Principles in Practice: A Concrete Loop

Here is how these principles combine in a real loop. Suppose you are using Claude Code to triage and fix all failing tests on the main branch of an open-source project:

# Step 1: Set a testable goal with bounded execution
claude -p "Run all tests, find every failure, and fix them one by one.
          Do not modify files outside the failing test's module.
          Commit each fix separately with a descriptive message.
          Stop after all tests pass or after 20 attempts, whichever comes first." \
  --max-turns 20

Let us map this command to the principles:

  1. Goal (Principle 1): "All tests pass" — testable via pytest exit code
  2. Autonomy (Principle 2): Claude reads CI failures, locates root causes, applies fixes without human prompting
  3. Verification (Principle 3): The test runner provides deterministic pass/fail after each attempt — Claude runs tests, reads the output, and decides its next action
  4. State (Principle 4): Git commits persist each fix. CLAUDE.md and the conversation context track progress. If the session crashes, the git history preserves what was done
  5. Error recovery (Principle 5): If a fix introduces a new failure, Claude reads the new test output, understands what broke, and adjusts — the built-in error recovery in Claude Code's tool loop handles this
  6. Bounds (Principle 6): --max-turns 20 provides a hard iteration cap
  7. Maker/checker (Principle 7): Claude Code's internal loop writes code then verifies it through test output — the verification signal comes from pytest, not from the model self-assessing

This pattern is the same one used by OpenHands (github.com/All-Hands-AI/OpenHands) on the SWE-bench benchmark, where an autonomous agent is given a GitHub issue and must produce a PR that resolves it — bounded by a maximum number of steps, verified by running the project's own test suite. SWE-Agent (github.com/princeton-nlp/SWE-Agent, 15K+ stars) follows an identical pattern on the same benchmark, with a dedicated verification step that runs test commands to validate each proposed fix.

Next Steps