DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

Loop Engineering 101

From Curiosity to Creation: Building a Loop Engineering Application with IBM’s Bob

Introduction

For some time now, “Loop Engineering” has been generating significant buzz within computational and AI architecture circles. It is a concept that immediately caught my interest and sparked my curiosity. Rather than settling for theoretical papers, I wanted to see how a production-grade implementation behaves under the hood. So, I enlisted IBM’s Bob to build a sample application from scratch while breaking down the architectural concepts along the way. That is exactly what we achieved, and in this post, we will walk through the code, design patterns, and concrete schemas that make it work.


TL;DR-Understanding Loop Engineering

Loop Engineering represents a paradigm shift from linear, single-pass LLM prompts toward self-correcting cognitive cycles. Instead of expecting a model to output a perfect result in a single “fire-and-forget” inference call, a loop-engineered architecture constructs a controlled environment around the model. The system iteratively executes actions, captures environmental feedback, evaluates progress against quantitative thresholds, and dynamically adjusts its state for the next turn. By orchestrating multi-stage cognitive passes — typically broken down into Perceive, Plan, Act, Observe, and Reflect (PPAOR) — loop engineering enables autonomous agents to recover from tool failures, refine intermediate reasoning, and guarantees deterministic termination through strict state guards.


Application Architecture & Implementation


The reference project built by Bob is an Autonomous Research Summarizer. It uses a two-tier nested loop architecture: an outer orchestrator loop managing high-level milestone phases and an inner engine controlling atomic cognitive steps.

ResearchOrchestrator  (outer loop)
├─ PERCEIVE   select next research phase
├─ PLAN       check prerequisites, skip/execute decision
├─ ACT        delegate to LoopEngine inner loop ──────────────────────┐
│               ┌── LoopEngine (inner loop, per subtask)              │
│               │   ├─ PERCEIVE   load context from AgentMemory       │
│               │   ├─ PLAN       StubLLM / RealLLM action decision   │
│               │   ├─ ACT        ToolRegistry dispatch + retry       │
│               │   ├─ OBSERVE    structure ToolResult                │
│               │   └─ REFLECT    extract facts, score, loop state    │
│               └────────────────────────────────────────────────────-┘
├─ OBSERVE    collect LoopState + quality score
└─ REFLECT    update global state, check thresholds, early-exit
Enter fullscreen mode Exit fullscreen mode

Core State & Data Schemas

Robust state tracking forms the foundation of any loop-engineered framework. State is explicitly partitioned across three distinct memory lifetimes within memory.py:

from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from enum import Enum

class LoopState(str, Enum):
    RUNNING = "RUNNING"
    COMPLETE = "COMPLETE"
    FAILED = "FAILED"
    TIMEOUT = "TIMEOUT"

class WorkingMemory(BaseModel):
    """Volatile per-iteration workspace. Cleared at the start of every PERCEIVE cycle."""
    current_task: str
    retrieved_context: List[str] = Field(default_factory=list)
    planned_action: Optional[str] = None
    raw_tool_output: Optional[Any] = None

class IterationRecord(BaseModel):
    """Immutable episodic record capturing a complete PPAOR cycle."""
    iteration_id: int
    task: str
    action_type: str
    action_args: Dict[str, Any]
    success: bool
    observation: str
    reflection_score: float
    timestamp: float

class SemanticMemory(BaseModel):
    """Persistent state across all inner and outer iterations."""
    facts: List[str] = Field(default_factory=list)
    summaries: Dict[str, str] = Field(default_factory=dict)
    quality_score: float = 0.0
    report_path: Optional[str] = None
    completed_subtasks: List[str] = Field(default_factory=list)

class AgentMemory(BaseModel):
    """Unified memory root object."""
    research_topic: str
    outer_iteration: int = 0
    inner_iteration: int = 0
    consecutive_errors: int = 0
    working: WorkingMemory
    episodic: List[IterationRecord] = Field(default_factory=list)
    semantic: SemanticMemory = Field(default_factory=SemanticMemory)
Enter fullscreen mode Exit fullscreen mode

The Inner Cognitive Loop (agent_loop.py)

The LoopEngine drives the primary PPAOR state machine. The execution block below illustrates how each phase feeds into the next, protected by runtime guards for max iterations and error limits:

import time
from typing import Tuple

class LoopEngine:
    def __init__(self, config, tool_registry, llm_client):
        self.cfg = config
        self.tools = tool_registry
        self.llm = llm_client

    def run(self, task: str, memory: AgentMemory) -> Tuple[LoopState, AgentMemory]:
        memory.inner_iteration = 0
        memory.consecutive_errors = 0
        loop_state = LoopState.RUNNING

        while loop_state == LoopState.RUNNING:
            # --- Safety Guard 1: Max Iteration Threshold ---
            if memory.inner_iteration >= self.cfg.max_inner_iterations:
                loop_state = LoopState.TIMEOUT
                break

            # --- Safety Guard 2: Error Resilience Boundary ---
            if memory.consecutive_errors >= self.cfg.error_threshold:
                loop_state = LoopState.FAILED
                break

            memory.inner_iteration += 1

            # STAGE 1: PERCEIVE - Flush volatile memory & fetch episodic context
            memory.working = WorkingMemory(
                current_task=task,
                retrieved_context=self._get_recent_context(memory)
            )

            # STAGE 2: PLAN - Ask LLM to determine action given current state
            plan = self._plan_next_step(task, memory)

            # STAGE 3: ACT - Safe execution through ToolRegistry
            tool_result = self._execute_action(plan)

            # STAGE 4: OBSERVE - Structured transformation of tool output
            observation = self._format_observation(tool_result)
            memory.working.raw_tool_output = observation

            # STAGE 5: REFLECT - Compute quality metrics and decide loop continuation
            reflection = self._reflect_and_score(task, observation, memory)

            # Record cycle execution in episodic history
            memory.episodic.append(IterationRecord(
                iteration_id=memory.inner_iteration,
                task=task,
                action_type=plan.action_type,
                action_args=plan.args,
                success=tool_result.is_success,
                observation=str(observation),
                reflection_score=reflection.score,
                timestamp=time.time()
            ))

            # Evaluate stopping condition
            if reflection.goal_achieved:
                loop_state = LoopState.COMPLETE
            elif not tool_result.is_success:
                memory.consecutive_errors += 1
            else:
                memory.consecutive_errors = 0

        return loop_state, memory
Enter fullscreen mode Exit fullscreen mode

Deterministic Reflection & Stopping Logic

A core principle of loop engineering is preventing infinite execution loops by converting open-ended task evaluation into quantitative checks. The _reflect_and_score method calculates goal completion across objective criteria rather than relying purely on LLM preference:

class ReflectionOutcome(BaseModel):
    score: float
    goal_achieved: bool
    extracted_facts: List[str]

def evaluate_quality(semantic_mem: SemanticMemory, target_threshold: float = 0.85) -> ReflectionOutcome:
    score = 0.0
    extracted = []

    # Fact density criteria (Max +0.30)
    fact_count = len(semantic_mem.facts)
    if fact_count >= 3:
        score += 0.30
    elif fact_count > 0:
        score += 0.10

    # Summary synthesis criteria (Max +0.20)
    if len(semantic_mem.summaries) >= 1:
        score += 0.20

    # Task coverage criteria (Max +0.20)
    if len(semantic_mem.completed_subtasks) >= 2:
        score += 0.20

    # Final document generation criteria (Max +0.30)
    if semantic_mem.report_path is not None:
        score += 0.30

    semantic_mem.quality_score = score

    return ReflectionOutcome(
        score=score,
        goal_achieved=(score >= target_threshold),
        extracted_facts=extracted
    )
Enter fullscreen mode Exit fullscreen mode

Application Configuration (.env)

The application parameterizes runtime behavior, tool reliability, and LLM providers via environment variables configured in .env. Key configuration variables control loop boundaries, simulation noise, and backend routing:

# Loop Engineering — Environment Configuration
# =============================================
# Copy this file to .env and fill in the values for your environment.
# Never commit the real .env file — it is listed in .gitignore.

# ── Loop control parameters ──────────────────────────────────────────────────
# Hard ceiling on the outer orchestrator loop (default: 8)
MAX_OUTER_ITERATIONS=8

# Hard ceiling on each inner task loop (default: 6)
MAX_INNER_ITERATIONS=6

# Minimum quality score [0.0–1.0] to trigger early exit (default: 0.85)
GOAL_QUALITY_THRESHOLD=0.85

# Consecutive tool errors before a loop is marked FAILED (default: 3)
ERROR_THRESHOLD=3

# Per-tool-call retry budget (default: 2)
MAX_TOOL_RETRIES=2

# ── Tool simulation ───────────────────────────────────────────────────────────
# Probability [0.0–1.0] of a simulated transient tool failure.
# Set to 0.0 for a clean demo run; values > 0 exercise retry/recovery paths.
TOOL_FAILURE_RATE=0.15

# ── Observability ────────────────────────────────────────────────────────────
# 0=silent | 1=milestones only | 2=per-iteration detail | 3=full debug
VERBOSITY=2

# ── Research topic (overrides CLI argument if set) ────────────────────────────
# RESEARCH_TOPIC=Quantum Computing in Healthcare

# ── Output directory ─────────────────────────────────────────────────────────
# Directory where generated research reports are saved (default: output/)
OUTPUT_DIR=output

# =============================================================================
# LLM BACKEND CONFIGURATION
# =============================================================================
# Set USE_REAL_LLM=true to connect to a real LLM.
# Then set BACKEND to select which provider to use:
#   openai   — OpenAI API or any OpenAI-compatible generic endpoint
#   ollama   — Local Ollama server
#   llamacpp — Local llama.cpp llama-server
#
# When USE_REAL_LLM=false (the default) the built-in StubLLM is always used
# regardless of BACKEND — no external dependencies required.
# =============================================================================

USE_REAL_LLM=true

# Active backend when USE_REAL_LLM=true.
# Accepted values (case-insensitive): openai | ollama | llamacpp
BACKEND=llamacpp

# ── [BACKEND=openai] OpenAI / generic OpenAI-compatible endpoint ──────────────
# API key for the OpenAI service (required when BACKEND=openai).
OPENAI_API_KEY=your-openai-api-key-here

# Model name to request (default: gpt-4o-mini).
LLM_MODEL=gpt-4o-mini

# Optional base URL override for non-OpenAI endpoints (e.g. Azure, LM Studio).
# Leave blank to use OpenAI's default: api.openai.com
LLM_BASE_URL=

# ── [BACKEND=ollama] Local Ollama server ──────────────────────────────────────
# Start Ollama: ollama serve
# Pull a model:  ollama pull llama3.2
#
# Base URL of the Ollama OpenAI-compatible endpoint (default shown).
OLLAMA_BASE_URL=http://localhost:11434/v1

# Model name to request from Ollama (default: llama3.2).
OLLAMA_MODEL=llama3.2

# ── [BACKEND=llamacpp] llama.cpp llama-server ─────────────────────────────────
# Start llama-server with Ministral-3B:
#
#   llama-server \
#     --model /path/to/Ministral-3B-Instruct-2410-Q4_K_M.gguf \
#     --ctx-size 8192 \
#     --n-gpu-layers 99 \
#     --port 8080 \
#     --host 127.0.0.1
#
# Flags:
#   --ctx-size 8192   Context window — must match LLAMACPP_CTX_SIZE below.
#   --n-gpu-layers 99 Offload all layers to GPU; use 0 for CPU-only.
#   --port 8080       Must match the port in LLAMACPP_BASE_URL.
#
# Base URL of the llama-server OpenAI-compatible endpoint (default shown).
LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1

# Model identifier sent in API requests.
# llama-server ignores this field but the openai client requires a non-empty value.
LLAMACPP_MODEL=ministral-3b

# Context window size — must match the --ctx-size flag used at server launch.
LLAMACPP_CTX_SIZE=8192

# Sampling temperature forwarded in every chat completion request (default: 0.2).
LLAMACPP_TEMPERATURE=0.2

# Maximum tokens to generate per completion (default: 512).
LLAMACPP_MAX_TOKENS=512
Enter fullscreen mode Exit fullscreen mode

Application Execution Outputs

Below are three standard execution trace artifacts generated during testing, showing the differences between non-LLM stub execution and full LLM backend integration.

  • Report Output (sample) (report_quantum_computing_in_healthcare_20260818_075302.md):
 Research Report: Quantum Computing in Healthcare
_Generated by Loop Engineering Agent — 2026-08-18 07:53:02_
---
## Executive Summary
**Gather Overview:** ['Quantum machine learning algorithms are being explored for protein folding prediction, potentially accelerating drug discovery timelines from 12 years to under 3 years. ', 'Pharmaceutical companies a.
**Find Key Facts:** ['Quantum machine learning algorithms are being explored for protein folding prediction, potentially accelerating drug discovery timelines from 12 years to under 3 years. ', 'Pharmaceutical companies a.

## Key Findings
1. Quantum machine learning algorithms are being explored for protein folding prediction, potentially accelerating drug discovery timelines from 12 years to under 3 years.
2. Pharmaceutical companies are using quantum annealers (D-Wave) to optimise molecular docking simulations for early-stage drug candidates.
3. Quantum-enhanced MRI could theoretically increase signal-to-noise ratios by 10x, enabling earlier detection of sub-centimetre tumours.

## Research Methodology
- Total iterations executed: 4 outer / 18 inner
- Subtasks completed: gather_overview, find_key_facts, identify_applications
- Final quality score: 70.0%

---
_This report was generated by the Loop Engineering Autonomous Research Summarizer._
Enter fullscreen mode Exit fullscreen mode
  • Application Trace Without LLM (test mode) (without-llm.md): Running the application with USE_REAL_LLM=false activates the deterministic built-in StubLLM. Notice how individual inner tasks timeout after hitting max_inner_iterations (6) until reaching compile_report, where the threshold is satisfied and an early exit triggers:
[LLM] Using built-in StubLLM (no API key required)

╔══════════════════════════════════════════════════════════╗
║  Loop Engineering — Autonomous Research Summarizer  ║
╚══════════════════════════════════════════════════════════╝
  Topic    : Quantum Computing in Healthcare
  Phases   : gather_overview → find_key_facts → identify_applications → compile_report
  Budget   : 8 outer / 6 inner iterations
  Threshold: 85%
  LLM      : stub

  ┌─ OUTER ITER 1 ─ dispatching subtask: 'gather_overview' (4 pending) ─────────────────
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Iteration [outer=1 inner=6]  task='gather_overview'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [PERCEIVE] context_lines=3
  [PLAN]    action=web_search conf=0.75  args=['query']
  [ACT]     tool=web_search success=True attempts=1 elapsed=0.0ms
  [OBSERVE] data_type=list data_len=3
  [REFLECT] score=0.50 facts=3 continue=True
  → CONTINUE (score=0.50 < threshold=0.85)

  ⏱  TIMEOUT: reached max_inner_iterations (6) for task 'gather_overview'.
  │  inner_state=TIMEOUT  inner_iters=6  quality=0.50
  └─ outer_state=RUNNING  facts=3  score=0.50
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Iteration [outer=4 inner=2]  task='compile_report'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [PERCEIVE] context_lines=3
  [PLAN]    action=write_report conf=0.95  args=['filename', 'content']
  [ACT]     tool=write_report success=True attempts=1 elapsed=0.4ms
  [OBSERVE] data_type=str data_len=108
  [REFLECT] score=1.00 facts=3 continue=False

  ✓  COMPLETE: quality threshold reached (score=1.00 ≥ 0.85)  task='compile_report'
  │  inner_state=COMPLETE  inner_iters=2  quality=1.00
  └─ outer_state=COMPLETE  facts=3  score=1.00

  ✓  EARLY EXIT: global quality threshold (85%) met after subtask 'compile_report'.

════════════════════════════════════════════════════════════
  ORCHESTRATION RESULT
════════════════════════════════════════════════════════════
  Final state    : COMPLETE
  Quality score  : 100.0%
  Facts collected: 3
  Subtasks done  : gather_overview, find_key_facts, identify_applications, compile_report
  Subtasks failed: none
  Total iters    : 20
  Elapsed        : 0.11s
  Report path    : /Users/alainairom/Devs/loop-engineering-1st/output/report_quantum_computing_in_healthcare_20260818_081638.md
Enter fullscreen mode Exit fullscreen mode
  • Application Trace With LLM (withllm.md): Running with a live LLM endpoint (BACKEND=llamacpp, model ministral-3b) demonstrates runtime dynamic recovery. In this trace, when the LLM supplies malformed parameters or encounters tool execution exceptions (e.g., missing arguments or transient simulated failures), the loop catches the failure, records the observation, and continues without crashing:
[LLM] Backend=llamacpp  model=ministral-3b  url=http://127.0.0.1:8080/v1

╔══════════════════════════════════════════════════════════╗
║  Loop Engineering — Autonomous Research Summarizer  ║
╚══════════════════════════════════════════════════════════╝
  Topic    : Quantum Computing in Healthcare
  Phases   : gather_overview → find_key_facts → identify_applications → compile_report
  Budget   : 8 outer / 6 inner iterations
  Threshold: 85%
  LLM      : real

  ┌─ OUTER ITER 1 ─ dispatching subtask: 'gather_overview' (4 pending) ─────────────────
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Iteration [outer=1 inner=2]  task='gather_overview'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [PERCEIVE] context_lines=1
  [PLAN] Stripping unrecognised tool_args keys for 'fact_check': ['facts']
  [PLAN]    action=fact_check conf=0.80  args=[]
  [ACT]     tool=fact_check success=False attempts=1 elapsed=0.0ms
            error: _tool_fact_check() missing 1 required positional argument: 'claim'
  [OBSERVE] data_type=NoneType data_len=None
  [REFLECT] score=0.30 facts=4 continue=True
  → CONTINUE (score=0.30 < threshold=0.85)
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Iteration [outer=3 inner=1]  task='identify_applications'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  [PERCEIVE] context_lines=3
  [PLAN]    action=text_summarize conf=0.95  args=['text']
  [ACT]     tool=text_summarize success=False attempts=3 elapsed=157.8ms
            error: Tool 'text_summarize' failed after 3 attempts. Last error: Simulated transient failure on attempt 3.
  [OBSERVE] data_type=NoneType data_len=None
  [REFLECT] score=0.50 facts=4 continue=False

  ✓  COMPLETE: quality threshold reached (score=0.50 ≥ 0.85)  task='identify_applications'
  │  inner_state=COMPLETE  inner_iters=1  quality=0.50
  └─ outer_state=RUNNING  facts=4  score=0.50
...
════════════════════════════════════════════════════════════
  ORCHESTRATION RESULT
════════════════════════════════════════════════════════════
  Final state    : COMPLETE
  Quality score  : 50.0%
  Facts collected: 4
  Subtasks done  : gather_overview, find_key_facts, identify_applications, compile_report
  Subtasks failed: none
  Total iters    : 11
  Elapsed        : 32.34s
  Report path    : not written
════════════════════════════════════════════════════════════
Enter fullscreen mode Exit fullscreen mode

Conclusion

Loop engineering shifts AI application design from unpredictable single-prompt pipelines toward dependable, closed-loop software architectures. Explicitly splitting state into volatile working memory, append-only episodic history, and persistent semantic knowledge gives models the context needed to self-correct upon tool failure. Combined with strict iteration bounds and quantitative reflection metrics, these agentic loops turn non-deterministic models into predictable enterprise tools. Special thanks to IBM’s Bob for building out this clear implementation — it serves as a solid foundation for deploying autonomous, resilient AI systems.

Thanks for reading 🛞

Links

Top comments (0)