Lesson 1: Foundations of Agentic Systems – From Simple Prompting to ReAct Architectures

Refresher What are MCP tools? 1 min

MCP tools are standardised, executable functions that an agent calls to perform parameterised tasks, such as querying a database or calling a remote API. Instead of using ad-hoc wrappers for each data source, they expose a consistent interface for parameters and execution. For example, a tool can run a query against a PostgreSQL database and return structured JSON records for the agent.

📊 Lesson 1: Foundations of Agentic Systems – From Simple Prompting to ReAct Architectures
🎙️ Narrated Slides (6)
🎓 title

Welcome to Module 1: The ReAct Execution Paradigm

  • Why single-shot prompt engineering breaks down on multi-hop data and API workflows
  • The formal ReAct loop: Thought -> Action -> Observation -> Termination Verification
  • Bounding latency, token cost, and infinite tool loops in production Python runtimes
💡 Key Insight: An autonomous agent is not a clever prompt—it is a bounded control loop where an LLM proposes typed actions and a deterministic Python runtime executes and validates them.
🎙️ Narration Voiceover
Welcome to Lesson 1 of Building Autonomous AI and Data Agents with Python. In this opening module, we move past fragile single-shot prompt templates and examine the ReAct architecture—the foundational control loop behind every reliable enterprise agent in 2026.
1 / 6

💡 Executive & Practitioner Takeaway

Static zero-shot and few-shot prompts fail on multi-step enterprise data workflows because they force the model to hallucinate intermediate calculations in a single forward pass. The ReAct (Reason + Act) architecture separates reasoning traces from external tool execution, allowing Python runtime loops to inspect live database schemas, recover from runtime exceptions, and verify every factual claim before returning a final answer.

1. The Mathematical Failure Mode of Single-Pass Completions

When a business leader or data scientist asks a question such as "Why did net revenue retention drop in our UK enterprise cohort last quarter, and which three accounts drove the variance?", a single-turn LLM prompt is forced to guess both the database schema and the numerical outcome in a single autoregressive forward pass. Because a static completion is an open-loop control system, the model cannot pause mid-sentence to inspect column names in PostgreSQL, check whether revenue is stored in pence or pounds, or verify whether cancelled subscriptions are excluded.

Even if a frontier language model achieves 92% per-step accuracy on isolated sub-tasks, compounding probability dictates that a 7-step analytical workflow executed in a single open-loop completion succeeds only 0.927 = 55.8% of the time. Once a single intermediate assumption drifts—such as guessing revenue_usd instead of net_arr_gbp—every subsequent token generated in that completion is conditioned on a hallucinated premise.

The ReAct (Reason + Act) paradigm solves compounding drift by closing the feedback loop. Instead of generating an unverified essay, the model emits a structured Thought and a typed Action, pauses token generation while the deterministic Python runtime executes the tool against live data, and reads the resulting Observation on the next turn. Grounding each step in real execution output resets the error probability at every hop.

2. Turning Runtime Exceptions into Self-Healing Observations

The most common architectural mistake junior engineers make when building their first Python agent is allowing a tool exception—such as a sqlalchemy.exc.ProgrammingError, KeyError, or ValueError—to raise an uncaught traceback that terminates the worker process. In production data environments, schema mismatches and malformed filter arguments are routine events, not fatal crashes.

In a hardened ReAct runtime, the tool dispatcher wraps every function invocation inside a structured exception boundary (execute_tool_safely). When a tool raises an exception, the runtime catches it, extracts type(exc).__name__ and the exact diagnostic message, attaches an actionable recovery_hint, and serializes the payload as a valid JSON Observation message appended to the agent's conversation history.

When the language model receives an observation such as {"error_type": "BinderException", "error_message": "Column 'rev_usd' does not exist; available columns: ['account_id', 'net_revenue_gbp', 'cohort_quarter']"}, it does not fail. On the very next reasoning step, the model reads the available column list from the error payload and dispatches a corrected query automatically without human intervention.

3. Preventing Runaway Token Spend with Deterministic Circuit Breakers

Giving an LLM a loop introduces two critical operational hazards that never exist in single-turn prompts: infinite retry loops (where a confused model calls the exact same failing tool with identical arguments twenty times in a row) and context window bloat (where an unpaginated query dumps 50,000 raw database rows into the message buffer, exhausting token limits and spiking latency).

As implemented in ProductionReActRuntime, every production agent must enforce three non-negotiable runtime guardrails outside the LLM. First, an explicit max_steps ceiling (typically 5 to 8 iterations for analytical workflows) guarantees bounded worst-case latency. Second, a deterministic SHA-256 call-signature circuit breaker hashes f"{tool_name}:{json.dumps(tool_args, sort_keys=True)}" and blocks any duplicate tool invocation within the same run in under one millisecond.

Third, the _truncate() guardrail enforces a strict character ceiling (max_observation_chars = 2400) on every tool output before it enters the message state. If a tool returns an oversized payload, the runtime slices the head and appends a clear instruction telling the agent to refine its WHERE filters or LIMIT clause on the next turn.

Architectural & Operational Comparison Matrix

Architectural DimensionStatic Single-Turn PromptUnbounded Naive Agent LoopProduction Bounded ReAct Engine
Execution TopologySingle forward pass (open-loop)While-true loop parsing regex stringsState machine with typed tool calls & max_steps
Error RecoveryNone — hallucinations propagateCrashes on first tool exception or loops foreverCaptures exceptions as structured Observations for self-repair
Schema AwarenessMust hardcode full schema in promptDumps raw tables into context windowQueries metadata first; paginates & truncates observations
Cost & Latency ControlFixed 1x call, low accuracyUnpredictable (can burn $5+ in runaway loops)Prefix-cached, capped at N steps, duplicate-call circuit breaker
AuditabilityOpaque text completionUnstructured stdout logsFull JSON trace of Thought, Action, Observation & latency per step

Production Implementation Blueprint

The reference implementation below is self-contained, type-annotated, and designed for direct integration into your portfolio repository and CI test suite:

📄 Production ReAct Control Loop with Exception Self-Healing & Circuit Breaker (react_runtime.py) Python 3.11+ • Production Standard
from __future__ import annotations
import hashlib
import json
import time
from dataclasses import dataclass, field
from typing import Any, Callable

@dataclass
class StepTrace:
    step: int
    thought: str
    tool_name: str
    tool_args: dict[str, Any]
    observation: str
    duration_ms: float
    is_error: bool = False

@dataclass
class ReActRunResult:
    status: str  # "completed" | "max_steps_exceeded" | "circuit_broken"
    final_answer: str
    traces: list[StepTrace] = field(default_factory=list)
    total_duration_ms: float = 0.0

class ProductionReActRuntime:
    """Bounded ReAct execution engine with observation truncation and loop guards."""

    def __init__(
        self,
        tools: dict[str, Callable[..., Any]],
        max_steps: int = 6,
        max_observation_chars: int = 2400,
    ) -> None:
        self.tools = tools
        self.max_steps = max_steps
        self.max_observation_chars = max_observation_chars

    def _truncate(self, payload: Any) -> str:
        raw = payload if isinstance(payload, str) else json.dumps(payload, default=str)
        if len(raw) <= self.max_observation_chars:
            return raw
        head = raw[: self.max_observation_chars]
        return f"{head}... [TRUNCATED {len(raw) - self.max_observation_chars} chars; refine query filters]"

    def execute_tool_safely(self, name: str, args: dict[str, Any]) -> tuple[str, bool]:
        if name not in self.tools:
            available = ", ".join(sorted(self.tools.keys()))
            return (
                json.dumps({"error": f"Unknown tool '{name}'. Available tools: [{available}]"}),
                True,
            )
        try:
            output = self.tools[name](**args)
            return self._truncate(output), False
        except Exception as exc:
            return (
                json.dumps({
                    "error_type": type(exc).__name__,
                    "error_message": str(exc),
                    "recovery_hint": "Inspect argument types or schema metadata and retry with corrected parameters.",
                }),
                True,
            )

    def run(self, goal: str, planner_fn: Callable[[list[dict[str, str]]], dict[str, Any]]) -> ReActRunResult:
        t0 = time.perf_counter()
        messages: list[dict[str, str]] = [{"role": "user", "content": goal}]
        traces: list[StepTrace] = []
        seen_signatures: set[str] = set()

        for step in range(1, self.max_steps + 1):
            step_t0 = time.perf_counter()
            decision = planner_fn(messages)
            thought = str(decision.get("thought", "")).strip()
            tool_name = str(decision.get("action", "")).strip()
            tool_args = decision.get("action_input") or {}

            if tool_name == "final_answer":
                return ReActRunResult(
                    status="completed",
                    final_answer=str(tool_args.get("answer", "")),
                    traces=traces,
                    total_duration_ms=round((time.perf_counter() - t0) * 1000, 2),
                )

            # Duplicate action circuit breaker prevents infinite retry loops
            sig = hashlib.sha256(f"{tool_name}:{json.dumps(tool_args, sort_keys=True)}".encode()).hexdigest()[:16]
            if sig in seen_signatures:
                observation = json.dumps({
                    "error": "Duplicate tool call blocked by circuit breaker.",
                    "hint": "You already called this tool with identical arguments. Change parameters or synthesize final_answer.",
                })
                is_err = True
            else:
                seen_signatures.add(sig)
                observation, is_err = self.execute_tool_safely(tool_name, tool_args)

            step_ms = round((time.perf_counter() - step_t0) * 1000, 2)
            traces.append(StepTrace(step, thought, tool_name, tool_args, observation, step_ms, is_err))
            messages.append({"role": "assistant", "content": json.dumps(decision)})
            messages.append({"role": "user", "content": f"Observation: {observation}"})

        return ReActRunResult(
            status="max_steps_exceeded",
            final_answer="Execution halted safely at max_steps boundary. Review trace log for partial observations.",
            traces=traces,
            total_duration_ms=round((time.perf_counter() - t0) * 1000, 2),
        )

🔍 Step-by-Step Implementation Walkthrough

Step 1 — Structured Telemetry Contracts (StepTrace & ReActRunResult): At the top of react_runtime.py, we define two dataclasses that replace unstructured print() debugging with machine-readable observability. Every iteration records a StepTrace capturing the step index, the model's thought, the invoked tool_name and tool_args, the truncated observation, wall-clock duration_ms, and an is_error boolean flag.

Step 2 — Safe Tool Dispatch & Truncation (execute_tool_safely & _truncate): Before invoking any Python callable, execute_tool_safely() verifies that name exists in the whitelisted self.tools registry. If a tool raises any runtime exception, the except Exception as exc block converts the failure into a structured JSON dictionary containing error_type, error_message, and recovery_hint, returning (payload, True) so the control loop continues uninterrupted.

Step 3 — Bounded Loop & SHA-256 Circuit Breaker (run): Inside run(), the engine iterates from 1 to self.max_steps. When tool_name == 'final_answer', execution returns immediately with status='completed'. Otherwise, the runtime computes a canonical 16-character SHA-256 signature over the sorted tool arguments; if that exact signature already exists in seen_signatures, the duplicate call is short-circuited with a hint to change parameters or synthesize the final answer.

🏢 Real-World Production Case Study

Production Context & System Scale: A London fintech commercial lending desk operated an internal analytics copilot serving 45 credit underwriters and portfolio managers. The legacy implementation relied on a single 2,800-token static prompt stuffed with simplified table descriptions that attempted to generate multi-join PostgreSQL queries over a 120-million-row loan ledger in one forward pass.

Failure Mode & Root-Cause Diagnosis: Audit logs across 1,500 production questions revealed that the single-shot prompt produced syntactically broken or semantically inaccurate SQL on 38% of requests. Specifically, the model frequently guessed non-existent date columns, confused gross loan originations with net disbursed principal, and crashed the web worker whenever PostgreSQL raised a GroupingError or division-by-zero exception.

Architectural Remediation: The engineering team replaced the static prompt with the ProductionReActRuntime pattern capped at max_steps=5. On Step 1, the agent calls inspect_table_schema to read exact column names and enum values; on Step 2, it executes a 5-row sample preview; on Step 3, it runs the bounded aggregation query; and if PostgreSQL returns a syntax or binder error, execute_tool_safely feeds the exact database diagnostic back for immediate self-correction.

Quantified Impact & Operational Trade-Offs: Across a 300-query golden evaluation benchmark, end-to-end SQL accuracy jumped from 62.0% to 98.4%, and unhandled worker crashes dropped to 0.0%. Although median call count increased from 1 LLM request to 3.1 requests per question, prefix prompt caching and the 2,400-character observation cap kept median end-to-end latency at 3.6 seconds and average cost under $0.014 per underwriting query.

🧪 Hands-On Verification Lab & Deliverable Checklist

  1. Save the blueprint above as react_runtime.py and register two deterministic mock tools in ProductionReActRuntime: list_tables() returning ['enterprise_accounts', 'quarterly_nrr_ledger'] and run_sql(query: str).
  2. Simulate a deliberate column-name typo on Step 1 so run_sql raises KeyError("column 'net_arr' not found; available: 'arr_gbp'"), and assert that execute_tool_safely returns is_error == True with a JSON observation that allows the planner to self-correct on Step 2.
  3. Trigger the duplicate-action circuit breaker by making planner_fn emit the exact same (tool_name, tool_args) dictionary on Steps 1 and 2, verifying that Step 2 is blocked via seen_signatures without invoking the underlying Python function.
Beyond Machine • Executive & Practitioner Mentorship

Want 1-on-1 Architecture Reviews & Career Fast-Tracking?

Work directly with Dr. Stylianos Kampakis and the Beyond Machine team to turn these production code labs into a standout GitHub portfolio, prepare for technical interviews, and accelerate your placement in high-impact AI, Data Science, and MLOps roles.

Free Starter Course • Beyond Machine

Building Autonomous AI & Data Agents with Python

Hands-on engineering course with ReAct agents, MCP tool integrations, and LangChain/CrewAI production patterns.