autonomous-loops

Autonomous loop patterns: self-improving workflows, continuous feedback loops, quality gates, loop termination conditions, retry strategies, bounded iteration, and convergence detection.

Autonomous Loops

Patterns for building self-improving, bounded autonomous loops: continuous feedback cycles, quality gates, convergence detection, retry strategies, loop health monitoring, and safe termination conditions.

When to Use

  • Building self-correcting agent workflows that iterate until quality thresholds are met
  • Implementing retry loops with exponential backoff and jitter
  • Designing convergence detection (stop when improvements plateau)
  • Creating quality-gated iteration (loop until lint/test/quality checks pass)
  • Building bounded iteration with hard limits to prevent runaway loops
  • Implementing progressive refinement (draft → review → revise → finalize)

Core Concepts

1. Loop Anatomy

Every autonomous loop has four phases:

┌─────────┐    ┌──────────┐    ┌───────────┐    ┌────────────┐
│  GENERATE│───▶│ VALIDATE │───▶│  IMPROVE  │───▶│ TERMINATE? │
│          │    │          │    │           │    │            │
│ - Draft  │    │ - Quality│    │ - Diff    │    │ - Converged│
│ - Plan   │    │   gates  │    │ - Fix     │    │ - Max iters│
│ - Execute│    │ - Score  │    │ - Retry   │    │ - Timed out│
└─────────┘    └──────────┘    └───────────┘    └────────────┘
       ▲                                                    │
       │              ┌───────────┐                         │
       └──────────────│  LOG &   │◀────────────────────────┘
                      │  METRICS │
                      └──────────┘

2. Bounded Iteration Loop

Python — Generic Autonomous Loop with Quality Gates:

from dataclasses import dataclass, field
from typing import Callable, Any
from enum import Enum
import logging
import time

logger = logging.getLogger(__name__)


class LoopExitReason(Enum):
    CONVERGED = "converged"
    MAX_ITERATIONS = "max_iterations"
    TIMEOUT = "timeout"
    QUALITY_PASSED = "quality_passed"
    QUALITY_FAILED = "quality_failed"
    ERROR = "error"


@dataclass
class LoopState:
    """Carries mutable state through loop iterations."""
    iteration: int = 0
    score: float = 0.0
    previous_score: float = 0.0
    result: Any = None
    errors: list[str] = field(default_factory=list)
    exit_reason: LoopExitReason | None = None
    started_at: float = field(default_factory=time.time)


@dataclass
class LoopConfig:
    max_iterations: int = 10
    convergence_threshold: float = 0.01  # Score delta below which we stop improving
    quality_threshold: float = 0.85      # Minimum acceptable score
    timeout_seconds: float = 300.0       # 5 minute hard limit
    backoff_base: float = 1.0            # Retry backoff base seconds
    backoff_max: float = 30.0            # Max backoff seconds


def autonomous_loop(
    generate_fn: Callable[[LoopState], Any],
    validate_fn: Callable[[Any], dict],   # Returns {"score": 0.0-1.0, "passed": bool, "feedback": str}
    improve_fn: Callable[[Any, dict], Any],  # Takes result + feedback, returns improved result
    config: LoopConfig = LoopConfig(),
) -> LoopState:
    """
    Run a bounded autonomous loop with quality gates and convergence detection.

    The loop continues until one of:
    - Quality gate passes (score >= threshold)
    - Score converges (delta < convergence_threshold for 2 consecutive iters)
    - Max iterations reached
    - Timeout exceeded
    """
    state = LoopState()

    while True:
        # Timeout check
        elapsed = time.time() - state.started_at
        if elapsed > config.timeout_seconds:
            state.exit_reason = LoopExitReason.TIMEOUT
            logger.warning("Loop timed out after %.1fs at iteration %d", elapsed, state.iteration)
            break

        # Max iterations check
        if state.iteration >= config.max_iterations:
            state.exit_reason = LoopExitReason.MAX_ITERATIONS
            logger.warning("Loop hit max iterations (%d), best score: %.3f",
                          config.max_iterations, state.score)
            break

        state.iteration += 1
        logger.info("=== Loop iteration %d/%d (score: %.3f) ===",
                    state.iteration, config.max_iterations, state.score)

        # Phase 1: Generate / Execute
        try:
            if state.iteration == 1:
                state.result = generate_fn(state)
            else:
                # On subsequent iterations, improve based on previous feedback
                feedback = {"score": state.score, "errors": state.errors}
                state.result = improve_fn(state.result, feedback)
        except Exception as e:
            state.errors.append(str(e))
            logger.error("Generation failed at iteration %d: %s", state.iteration, e)
            state.exit_reason = LoopExitReason.ERROR
            break

        # Phase 2: Validate
        try:
            validation = validate_fn(state.result)
            state.previous_score = state.score
            state.score = validation["score"]
            state.errors = validation.get("errors", [])
        except Exception as e:
            state.errors = [str(e)]
            state.score = 0.0
            logger.error("Validation failed at iteration %d: %s", state.iteration, e)

        # Phase 3: Check exit conditions

        # Quality gate passed
        if validation.get("passed", False) and state.score >= config.quality_threshold:
            state.exit_reason = LoopExitReason.QUALITY_PASSED
            logger.info("Quality gate passed: score %.3f >= %.3f",
                       state.score, config.quality_threshold)
            break

        # Convergence detection (score not improving meaningfully)
        if state.iteration >= 2:
            score_delta = abs(state.score - state.previous_score)
            if score_delta < config.convergence_threshold:
                state.exit_reason = LoopExitReason.CONVERGED
                logger.info("Score converged at %.3f (delta: %.4f < %.4f)",
                           state.score, score_delta, config.convergence_threshold)
                break

        # Quality validation failed catastrophically
        if state.score < 0.1 and state.iteration >= 3:
            state.exit_reason = LoopExitReason.QUALITY_FAILED
            logger.error("Score too low (%.3f) after %d iterations, aborting",
                        state.score, state.iteration)
            break

        # Backoff before next iteration (avoids hammering resources)
        backoff = min(config.backoff_base * (2 ** (state.iteration - 1)), config.backoff_max)
        logger.debug("Backoff: %.1fs", backoff)
        time.sleep(backoff)

    logger.info("Loop exited: %s (iterations: %d, final score: %.3f, elapsed: %.1fs)",
                state.exit_reason.value, state.iteration, state.score,
                time.time() - state.started_at)

    return state

3. Quality Gates

Quality gates are the loop's decision function. Each gate produces a normalized score (0.0-1.0) and a pass/fail verdict.

from dataclasses import dataclass


@dataclass
class QualityGateResult:
    score: float           # 0.0 to 1.0
    passed: bool           # Above threshold?
    feedback: str          # Human-readable feedback
    errors: list[str] = field(default_factory=list)


def composite_quality_gate(
    checks: list[tuple[Callable, float]],  # (check_fn, weight)
    result: Any,
) -> QualityGateResult:
    """Combine multiple quality checks into a weighted score."""
    total_weight = sum(w for _, w in checks)
    weighted_sum = 0.0

    for check_fn, weight in checks:
        try:
            check_result = check_fn(result)
            weighted_sum += check_result * weight
        except Exception as e:
            weighted_sum += 0.0 * weight  # Failed check = 0 score
    score = weighted_sum / total_weight if total_weight > 0 else 0.0

    return QualityGateResult(
        score=score,
        passed=score >= 0.85,
        feedback=f"Composite quality score: {score:.3f}",
    )


# Example checks for code generation loop
def check_tests_pass(result: dict) -> float:
    """0.0 if tests fail, 1.0 if all pass."""
    test_results = result.get("test_results", {})
    if test_results.get("failed", 0) > 0:
        return 0.0
    return 1.0


def check_lint_clean(result: dict) -> float:
    """0.0 if lint errors, 1.0 if clean, 0.5 if warnings only."""
    lint_results = result.get("lint_results", {})
    errors = lint_results.get("errors", 0)
    warnings = lint_results.get("warnings", 0)
    if errors > 0:
        return 0.0
    if warnings > 5:
        return 0.5
    return 1.0


def check_coverage(result: dict) -> float:
    """Linear score based on test coverage percentage."""
    coverage = result.get("coverage_pct", 0)
    return min(coverage / 80.0, 1.0)  # 80% = 1.0


def code_quality_gate(result: dict) -> float:
    """Single quality score for code output."""
    checks = [
        (check_tests_pass, 0.4),
        (check_lint_clean, 0.2),
        (check_coverage, 0.3),
        (lambda r: 1.0 if r.get("no_todos") else 0.5, 0.1),
    ]
    gate_result = composite_quality_gate(checks, result)
    return gate_result.score

4. Retry Strategies

Not all failures are equal. Different error types need different retry strategies.

TypeScript — Smart Retry with Backoff and Jitter:

interface RetryConfig {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterFactor: number;  // 0.0-1.0, adds randomness to avoid thundering herd
  retryableErrors: string[];  // Only retry these error types
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxAttempts: 5,
  baseDelayMs: 1000,
  maxDelayMs: 30000,
  jitterFactor: 0.25,
  retryableErrors: ["TIMEOUT", "RATE_LIMIT", "NETWORK_ERROR", "TEMPORARY_FAILURE"],
};

function isRetryable(error: Error, config: RetryConfig): boolean {
  return config.retryableErrors.some(
    (code) => error.message.includes(code) || error.name.includes(code),
  );
}

function calculateBackoff(attempt: number, config: RetryConfig): number {
  // Exponential backoff with jitter
  const exponential = config.baseDelayMs * Math.pow(2, attempt - 1);
  const capped = Math.min(exponential, config.maxDelayMs);
  const jitter = capped * config.jitterFactor * (Math.random() - 0.5) * 2;
  return Math.max(0, capped + jitter);
}

async function smartRetry<T>(
  fn: () => Promise<T>,
  config: RetryConfig = DEFAULT_RETRY_CONFIG,
): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
    try {
      const result = await fn();

      if (attempt > 1) {
        console.log(`[retry] Succeeded on attempt ${attempt}/${config.maxAttempts}`);
      }
      return result;
    } catch (error: unknown) {
      lastError = error instanceof Error ? error : new Error(String(error));

      if (!isRetryable(lastError, config)) {
        throw lastError;  // Non-retryable — fail immediately
      }

      if (attempt === config.maxAttempts) {
        break;  // Will throw after loop
      }

      const delay = calculateBackoff(attempt, config);
      console.warn(
        `[retry] Attempt ${attempt}/${config.maxAttempts} failed: ${lastError.message}. ` +
        `Retrying in ${Math.round(delay)}ms`,
      );
      await sleep(delay);
    }
  }

  throw new Error(
    `Failed after ${config.maxAttempts} attempts. Last error: ${lastError?.message}`,
  );
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Usage: retry with specific config for rate-limited APIs
const rateLimitedRetry: RetryConfig = {
  ...DEFAULT_RETRY_CONFIG,
  maxAttempts: 8,
  baseDelayMs: 2000,
  maxDelayMs: 60000,
  retryableErrors: ["RATE_LIMIT", "429", "TIMEOUT"],
};

const response = await smartRetry(() => fetchWithRateLimit("/api/generate"), rateLimitedRetry);

5. Convergence Detection

Detect when the loop stops making meaningful progress and terminate early.

class ConvergenceDetector:
    """Detects when a metric has stopped improving meaningfully."""

    def __init__(
        self,
        window: int = 3,              # Look at last N iterations
        threshold: float = 0.01,      # Delta below which considered converged
        min_iterations: int = 3,      # Don't converge before this
    ):
        self.history: list[float] = []
        self.window = window
        self.threshold = threshold
        self.min_iterations = min_iterations

    def add_score(self, score: float) -> None:
        self.history.append(score)

    def is_converged(self) -> bool:
        """Check if scores have stabilized within the threshold."""
        if len(self.history) < self.min_iterations:
            return False

        recent = self.history[-self.window:]
        if len(recent) < 2:
            return False

        # Check consecutive deltas
        deltas = [abs(recent[i] - recent[i-1]) for i in range(1, len(recent))]
        return all(d < self.threshold for d in deltas)

    def trend(self) -> str:
        """Return 'improving', 'degrading', or 'stable'."""
        if len(self.history) < 2:
            return "unknown"
        delta = self.history[-1] - self.history[-2]
        if delta > self.threshold:
            return "improving"
        elif delta < -self.threshold:
            return "degrading"
        return "stable"


# Usage in a loop
detector = ConvergenceDetector(window=3, threshold=0.02)

for i in range(10):
    score = evaluate(current_result)
    detector.add_score(score)

    if detector.is_converged():
        print(f"Converged at iteration {i+1}, score={score:.3f}, trend={detector.trend()}")
        break

6. Loop Health Monitoring

Track loop metrics to detect problematic patterns (oscillation, runaway, degradation).

from dataclasses import dataclass, field


@dataclass
class LoopHealthMetrics:
    """Tracks health metrics across loop iterations."""
    scores: list[float] = field(default_factory=list)
    iteration_times: list[float] = field(default_factory=list)
    error_counts: list[int] = field(default_factory=list)

    @property
    def is_oscillating(self) -> bool:
        """Score is bouncing up and down without progress."""
        if len(self.scores) < 4:
            return False
        recent = self.scores[-4:]
        # Check for alternating up/down pattern
        directions = [recent[i] < recent[i+1] for i in range(len(recent)-1)]
        return all(directions[i] != directions[i+1] for i in range(len(directions)-1))

    @property
    def is_degrading(self) -> bool:
        """Last 3 iterations show consistent decline."""
        if len(self.scores) < 3:
            return False
        recent = self.scores[-3:]
        return all(recent[i] > recent[i+1] for i in range(len(recent)-1))

    @property
    def is_slowing_down(self) -> bool:
        """Iteration times are increasing."""
        if len(self.iteration_times) < 3:
            return False
        recent = self.iteration_times[-3:]
        return all(recent[i] < recent[i+1] for i in range(len(recent)-1))

    def summary(self) -> str:
        issues = []
        if self.is_oscillating:
            issues.append("OSCILLATING")
        if self.is_degrading:
            issues.append("DEGRADING")
        if self.is_slowing_down:
            issues.append("SLOWING")
        if issues:
            return f"⚠️ Loop health issues: {', '.join(issues)}"
        return "✅ Loop healthy"

Workflow: Progressive Refinement Loop

A concrete pattern for code improvement loops:

Iteration 1: Generate initial draft
Iteration 2: Fix lint errors
Iteration 3: Fix failing tests
Iteration 4: Improve edge cases
Iteration 5: Optimize performance
...
def progressive_refinement_loop(
    task: str,
    initial_draft: str,
    config: LoopConfig = LoopConfig(max_iterations=10),
) -> LoopState:
    """Iteratively refine code through quality-gated improvement."""

    def generate(state: LoopState) -> dict:
        return {"code": initial_draft, "task": task}

    def validate(result: dict) -> dict:
        code = result["code"]
        # Run actual checks
        test_result = run_tests(code)
        lint_result = run_linter(code)
        coverage = measure_coverage(code)

        score = code_quality_gate({
            "test_results": test_result,
            "lint_results": lint_result,
            "coverage_pct": coverage,
            "no_todos": "TODO" not in code and "FIXME" not in code,
        })

        feedback_parts = []
        if test_result.get("failed", 0) > 0:
            feedback_parts.append(f"{test_result['failed']} failing tests")
        if lint_result.get("errors", 0) > 0:
            feedback_parts.append(f"{lint_result['errors']} lint errors")
        if coverage < 80:
            feedback_parts.append(f"Low coverage: {coverage}%")

        return {
            "score": score,
            "passed": score >= 0.85,
            "feedback": "; ".join(feedback_parts) if feedback_parts else "All checks passed",
            "errors": feedback_parts,
        }

    def improve(result: dict, feedback: dict) -> dict:
        # Feed errors back into the generator for targeted fixes
        code = result["code"]
        task = result["task"]
        improvement_prompt = (
            f"Fix these issues in the code:\n{feedback.get('feedback', 'Unknown issues')}\n\n"
            f"Current code:\n{code}\n\n"
            f"Return only the improved code."
        )
        improved_code = llm_generate(improvement_prompt, max_tokens=4000)
        return {"code": improved_code, "task": task}

    return autonomous_loop(generate, validate, improve, config)

Anti-Patterns

Anti-PatternSymptomFix
No hard stopInfinite loops, runaway costsAlways set max_iterations + timeout
No backoffAPI rate limit bansExponential backoff with jitter
Retrying everythingWasting attempts on permanent errorsOnly retry retryable error types
No convergence checkContinuing after plateauTrack score deltas, stop when stable
Single quality metricPassing one check, failing othersComposite weighted quality gate
No health monitoringUndetected oscillation/degradationTrack trends, alert on anomalies
Blind iterationLoop doesn adapt to feedbackFeed validation errors back to generator

Success Checklist

  • Hard iteration limit set (max_iterations)
  • Hard timeout configured (timeout_seconds)
  • Quality gate with composite scoring defined
  • Convergence detector active
  • Exponential backoff with jitter configured
  • Only retryable errors trigger retries
  • Loop health metrics tracked (oscillation, degradation, slowdown)
  • Every iteration logged with score and duration
  • Exit reason recorded (converged, max_iterations, timeout, quality)
  • Best result preserved even if loop exits without passing