advanced~5h

ReAct Implementation From Scratch (Manual + Automated)

Build a ReAct-style agent in pure Python with zero frameworks — a minimal Agent class, a structured Thought/PAUSE/Action/PAUSE/Observation/Answer system prompt, a manual step-by-step walkthrough, and a fully automated agent_loop() controller.

agent
Speed:
User Requestobjective

Retrieve users list from SQLite DB, filter active records, and compile active_report.txt.

Step 1 of 8

Formulate task objective

Task: Fetch active users and generate database summary report.

5
Subtopics
1
Exercises
1
Projects
5
Quiz Qs
4
Flashcards
📚 Prerequisites(1)

🎓 Learning objectives

  • Implement a minimal conversational Agent class that tracks full message history
  • Design a ReAct system prompt using the Thought/PAUSE/Action/PAUSE/Observation/Answer protocol
  • Manually trace a multi-step ReAct reasoning loop to understand exactly how thought, action, and observation interleave
  • Build an automated agent_loop() controller that parses actions via regex and dispatches to tools without human intervention
  • Identify the brittleness of regex-based action parsing and know the production-grade alternatives

What is it?

A from-scratch ReAct implementation is a hand-rolled agent built without any orchestration framework (no LangChain, no CrewAI) — just a lightweight LLM wrapper (LiteLLM), a custom system prompt that encodes the ReAct reasoning protocol, and a small amount of Python control flow to drive the loop. It consists of two parts: a minimal Agent class that tracks conversation history and calls the LLM, and a ReAct system prompt that forces the model to alternate between Thought (reasoning), Action (tool call), and Observation (tool result) until it emits a final Answer. Building this from scratch — rather than using a framework's black-box AgentExecutor — makes every step of the reasoning loop visible and debuggable.

Why it exists

Frameworks like LangChain and CrewAI hide the ReAct loop behind abstractions (AgentExecutor, Crew.kickoff()), which is convenient in production but opaque when you're learning how agents actually work or debugging why one misbehaves. Implementing ReAct from scratch exists as a pedagogical and debugging exercise: it strips away the framework's magic and shows exactly what's being sent to the LLM at each step, how the loop decides to continue or stop, and how tool results get fed back in — knowledge that transfers directly to debugging any framework-based agent later.

Problem it solves

It solves the 'black box' problem when learning or debugging agents — when a CrewAI or LangChain agent behaves unexpectedly, understanding the underlying ReAct loop (built from scratch once) lets you reason about what the framework is likely doing internally, rather than treating it as magic. It also solves a genuine engineering need: sometimes a framework's abstractions add unnecessary overhead or don't fit a specific use case, and a lightweight hand-rolled loop (a few dozen lines of Python) is easier to maintain, extend, and reason about than a heavyweight framework dependency.

Intuition

Think of it like learning to drive a manual-transmission car before an automatic. An automatic (a framework's AgentExecutor) handles gear-shifting for you — convenient, but you don't learn what's actually happening between the engine and the wheels. Building a ReAct loop by hand is like learning stick shift: you feel every gear change (every Thought → PAUSE → Action → PAUSE → Observation transition), and that hands-on understanding makes you a much better driver (agent engineer) even after you go back to driving automatic in production.

Analogy

It's like the difference between using a GPS app that just tells you 'turn left in 200 feet' versus manually plotting a route on a paper map, step by step, noting every landmark. The paper-map version is slower and more manual, but you come away actually understanding the geography — so when the GPS app glitches later, you know how to recover.

Technical explanation

The implementation has three layers.

(1) The Agent class: wraps a list self.messages (the full conversation history) and two methods — call(message) which appends a user message, calls self.invoke(), appends and returns the assistant reply; and invoke() which sends the full self.messages list to an LLM via LiteLLM's completion() function (e.g., model='openai/gpt-4o') and extracts .choices[0].message.content.

(2) The ReAct system prompt: a fixed string instructing the model to run in a loop doing exactly one of five things per turn — Thought (reasoning), PAUSE (a forced pause before acting), Action (pick a tool from an explicit list with usage examples), PAUSE (wait for the result), Observation (the tool's return value, injected by the controller) — ending with Answer once enough information has been gathered. A worked example trace is embedded directly in the prompt so the model has a concrete pattern to imitate.

(3) The controller: either a human manually calling agent(prompt) repeatedly and pasting in observations (the manual version), or an automated agent_loop(query, system_prompt) function that uses a regex like re.compile(r'^Action: (\w+): (.*)$') to parse the model's Action line, look up the tool by name in a known_actions dict, call it, format the result as 'Observation: {result}', and feed it back in — looping until a line starting with 'Answer:' appears.

Architecture

Three components wired together: (1) MyAgent — thin LLM wrapper holding message-history state; (2) the ReAct system_prompt — a behavioral protocol string defining the five-step loop (Thought/PAUSE/Action/PAUSE/Observation) plus the tool spec (name, example invocation, description) and a worked example trace; (3) the controller — either manual (agent(prompt) called by hand between each step, observations pasted in by the developer) or automated (agent_loop() — a while True loop with regex-based Action-line parsing, a known_actions dict mapping tool names to Python callables, and a max-turns guard). Two example tools are used throughout: math (evaluates a Python expression) and lookup_population (returns a country's population from a lookup table).

Workflow

  1. Install litellm and set your LLM API key as an environment variable.
  2. Define the Agent class with self.messages, call, and invoke (via litellm.completion).
  3. Write the ReAct system prompt: framing sentence, the five-step protocol (Thought/PAUSE/Action/PAUSE/Observation), the tool spec section (name, example call, description for each tool), a worked example trace, and the closing stop-instruction ('whenever you have the answer, output it').
  4. Implement the tool functions (e.g., math(expr), lookup_population(country)).
  5. (Manual mode) Instantiate the agent, call it with the question, and manually call it again with blank input or 'Observation: ...' after each PAUSE/Action, reading the printed output at every step.
  6. (Automated mode) Write agent_loop(query, system_prompt, max_turns=5): initialize the agent, set current_prompt = query, loop calling agent(current_prompt), checking for 'Answer:' (break), 'Action:' (regex-parse, call tool, set current_prompt to the Observation string), or anything else (set current_prompt = '' to let it continue).
  7. Run agent_loop() on a multi-step question (e.g., 'What is the sum of the populations of India and Japan?') and confirm it resolves without manual intervention.

Example

import re from litellm import completion

REACT_SYSTEM_PROMPT = ''' You run in a loop and do JUST ONE thing in a single iteration:

  1. "Thought" to describe your thoughts about the input question.
  2. "PAUSE" to pause and think about the action to take.
  3. "Action" to decide what action to take from the list of actions available to you.
  4. "PAUSE" to pause and wait for the result of the action.
  5. "Observation" will be the output returned by the action. At the end of the loop, you produce an Answer.

The actions available to you are: math: e.g. math: (14 * 5) / 4 Evaluates mathematical expressions using Python syntax. lookup_population: e.g. lookup_population: India Returns the latest known population of the specified country.

Whenever you have the answer, stop the loop and output it to the user. Now begin solving: '''.strip()

class MyAgent: def init(self, system: str = ''): self.messages = [] if system: self.messages.append({'role': 'system', 'content': system})

def __call__(self, message: str = '') -> str:
    if message:
        self.messages.append({'role': 'user', 'content': message})
    result = self.invoke()
    self.messages.append({'role': 'assistant', 'content': result})
    return result

def invoke(self) -> str:
    response = completion(model='openai/gpt-4o', messages=self.messages)
    return response.choices[0].message.content

def math(expr: str) -> str: return str(eval(expr)) # demo only — never eval untrusted input in production

def lookup_population(country: str) -> str: table = {'India': '1400000000', 'Japan': '125000000'} return table.get(country.strip(), 'unknown')

known_actions = {'math': math, 'lookup_population': lookup_population} action_re = re.compile(r'^Action: (\w+): (.*)$')

def agent_loop(query: str, system_prompt: str = REACT_SYSTEM_PROMPT, max_turns: int = 8): agent = MyAgent(system_prompt) current_prompt = query for _ in range(max_turns): result = agent(current_prompt) print(result) if result.strip().startswith('Answer:'): return result actions = [action_re.match(line) for line in result.split('\n') if action_re.match(line)] if actions: tool_name, tool_arg = actions[0].groups() if tool_name not in known_actions: current_prompt = f'Observation: unknown tool {tool_name}, please retry' continue observation = known_actionstool_name current_prompt = f'Observation: {observation}' else: current_prompt = '' # let the model continue to the next PAUSE/step return 'Max turns reached without a final answer.'

agent_loop('What is the sum of the populations of India and Japan?')

Real-world usage

This exact from-scratch pattern — a system prompt encoding an explicit reasoning protocol plus a regex-based controller — is what underpins early agent frameworks' internals; LangChain's original ReAct AgentExecutor and Simon Willison's well-known 'llm' CLI ReAct implementation both follow this same Thought/Action/Observation shape. Teams building lightweight internal tools (a Slack-bot agent, a small automation script) often prefer this hand-rolled approach over pulling in a full framework dependency, since a self-contained ~100-line implementation is easier to audit and modify than a framework's black-box executor. It's also the standard teaching implementation used in most 'build an agent from scratch' tutorials because it makes every reasoning step inspectable.

Trade-offs

A hand-rolled ReAct loop gives full transparency and zero framework dependency, at the cost of doing yourself everything a framework would normally provide for free: robust output parsing (regex breaks on formatting deviations), tool validation, retries on malformed actions, and structured-output guarantees. For a learning exercise, a demo, or a genuinely small/simple production agent, hand-rolling is the right call. For a production system with many tools, complex branching, or a need for observability/tracing out of the box, a framework (LangGraph, CrewAI) or at minimum switching from regex parsing to structured JSON/function-calling output is the better choice.

Visual explanation

A loop diagram: [System Prompt: ReAct protocol] + [User Question] → LLM → outputs one of {Thought: ..., PAUSE, Action: tool_name: arg, Answer: ...} → if Action, a Python regex extracts (tool_name, arg) → the matching Python function is called → its return value is wrapped as 'Observation: ' and appended to the message history → loop repeats by calling the LLM again with the updated history → continues until the LLM outputs 'Answer: ...' which breaks the loop. Every arrow in this diagram corresponds to one line of visible Python code, unlike a framework's internal executor loop.

Advantages

  • Full transparency into every reasoning step — nothing hidden behind framework abstractions

  • Zero framework dependency — a self-contained ~100 lines of Python

  • Excellent for learning and debugging how ReAct-style loops actually work under the hood

  • Easy to audit and modify for small, tightly-scoped production use cases

Disadvantages

  • Regex-based action parsing is brittle — breaks on extra whitespace, casing differences, or mislabeled actions

  • No built-in tool validation, retries, or error handling — you must add all of it yourself

  • Doesn't scale well to many tools or complex branching without significant extra engineering

  • Missing production concerns frameworks provide out of the box: tracing, observability, structured-output guarantees

Common mistakes

  • Relying on regex parsing in production without hardening it — a single unexpected format deviation from the LLM silently breaks the loop

  • Forgetting the max_turns guard — without one, a confused agent can loop indefinitely, burning tokens on a never-resolving task

  • Writing vague tool descriptions in the system prompt — the model needs an explicit usage example for every tool or it will invent syntax

  • Not handling the case where the model calls a tool name that doesn't exist in known_actions — always have a fallback path that asks the model to retry

  • Treating this from-scratch pattern as production-ready as-is — it's for understanding the mechanics, not a production hardening reference

📂 Subtopics

📝 Quiz

💬 Deep Dive with AI

Next Step

Continue to Memory Types & Architecture for AI Agents