Building a ReAct Agent in Pure Python: Thought, Action, Observation
Write a ReAct agent loop from scratch in Python: the Thought, Action, Observation contract, a PAUSE-driven while loop, error feedback and two production failure modes.
This article is published in English.
Sources: Building a ReAct AI Agent from Scratch in Pure Python
A plan-and-execute agent has a neat trick: the model drafts a numbered list of steps up front, and plain Python walks through them. That works until the world disagrees with the plan. An endpoint moves, a search comes back with a 404, or the model simply guessed wrong about step two. The agent keeps marching through steps three to ten anyway, because nothing in its design lets it look up.
ReAct fixes that by replacing the full plan with a loop. Below, that loop gets built in pure Python, with no framework, before turning to the two ways it tends to break once real users hit it.
From a fixed itinerary to a feedback loop
Think of two ways to cross an unfamiliar forest. One traveler writes down "1 km north, then 500 m east" and follows it to the letter, which means they keep walking when a cliff shows up at the 200 m mark. The other stops at every few steps and reasons out loud: the way north is blocked by a cliff, so try west for a detour, and yes, there's a safe path over there.
The first traveler is the plan-and-execute agent. It's cheap in tokens and well organized, but brittle: if step 2 fails, steps 3 through 10 are worthless. The second is the ReAct agent (Reasoning plus Acting). It produces a single step at a time and rechecks its surroundings before the next one. That costs more tokens, but it holds up far better when things go sideways.
The running commentary the second traveler keeps is an internal monologue, and it's the mechanism that makes an agent feel adaptive instead of scripted. To be useful, though, that monologue has to be able to trigger real actions.
Tool calling in one paragraph
A language model can only emit text. To give it hands, you keep a plain dictionary that maps names to Python functions, something like {"get_weather": get_weather_api}. When the model's output contains the string get_weather, your code spots it and calls the matching function. The tools stay exactly the same in a ReAct agent. What changes is how the model decides to use them.
Here's the slightly odd part of the whole design. To make a model look like it's thinking for itself, you have to constrain it heavily.
The contract behind the monologue
Instead of asking for an answer, you bind the model to a strict output format and don't let it touch any tool until it has followed that format. The loop has three beats:
- Thought. The model reviews the question, everything gathered so far and any earlier failures, then writes down what to do next and why.
- Action. Based on that thought, it picks one tool from the list and writes out the exact input for it.
- Observation. This is the handoff that matters. The model has to stop. Your Python code takes over, reads the action, runs the real function (a live weather call, a database query, whatever) and returns the raw result to the model.
Each lap through the cycle gives the model fresh evidence to reason from. The cycle of hypothesize, execute and verify repeats until a thought finally reaches a conclusion the user can have. In short, the model reasons and decides, while ordinary code acts as referee, intercepting each tool call and feeding back what really happened.
On paper that's tidy. The practical question is how you make a text predictor stop mid-stream, wait for a script and then pick its train of thought back up.
Writing the loop by hand
Frameworks such as LangChain, CrewAI and AutoGen bury all of this behind one .run() call. That's pleasant until the agent spins in circles or invents a tool that doesn't exist. Debugging a black box is miserable, so it pays to know what's inside it.
Start with the system prompt, which sets the rules of engagement and defines the contract:
system_prompt = """
You are a dynamic AI Agent. You work in a continuous
loop of
- Thought,
- Action,
- Observation.
- PAUSE
Your available tools are:
- get_weather(location: str): Returns the current weather.
- calculate(expression: str): Evaluates a math expression.
Use the following format exactly:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [get_weather, calculate]
Action Input: the input to the action
PAUSE
(You will then receive an Observation from the environment)
Thought: I now know the answer
Final Answer: the final answer to the original input question
"""
The PAUSE line does the heavy lifting, and the prompt also pins down the exact field names (Question, Thought, Action, Action Input, Final Answer) so that a regular expression can find them reliably in free-form output. It tells the model to stop generating, which hands control back to Python so the tool can run and its output can be written into the context as an Observation.
Models don't obey format instructions perfectly, and you'll see that below. A prompt is only a rulebook, though. Something has to enforce it: catch the PAUSE, fire the function and keep things turning. That something is a while loop, the heartbeat of the agent. Every pass it prompts the model, parses the reply, runs any requested tool and appends the whole exchange to the history, stopping when the model gives a final answer or the turn budget runs out.
import re
def react_agent(self, user_question, max_turns=5):
# Initialize our conversational memory with the system prompt
messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": f"Question: {user_question}"}
]
turn_count = 0
while turn_count < max_turns:
print(f"\n--- Turn {turn_count + 1} ---")
# 1. Get the LLM's response (The Thought + Action)
response = my_react_agent.call_llm(messages)
print(response)
# Add the LLM's generation to the memory
messages.append({"role": "assistant", "content": response})
# 2. Check if the agent has reached a conclusion
if "Final Answer:" in response:
print("\n✅ Task Complete.")
#print(json.dumps(messages, indent=2))
return response.split("Final Answer:")[-1].strip()
# 3. Parse the Action and Action Input using Regex
action_match = re.search(r"Action: (.*)", response)
input_match = re.search(r"Action Input: (.*)", response)
if action_match and input_match:
action = action_match.group(1).strip()
action_input = input_match.group(1).strip()
# 4. The Observation Phase (Python takes control)
print(f"⚙️ System Executing: {action}({action_input})")
try:
action_input = json.loads(action_input)
observation_result = my_react_agent.execute_tool(action, action_input)
except (TypeError, ValueError, json.JSONDecodeError) as error_messgae:
observation_result = error_messgae
# Format the observation and feed it back to the agent
observation_text = f"Observation: {observation_result}"
messages.append({"role": "user", "content": observation_text})
print(observation_text)
else:
# If the LLM breaks the API contract, gently correct it
messages.append({"role": "user", "content": "Error: Invalid format. Please provide an Action and Action Input, or a Final Answer."})
turn_count += 1
return "❌ Agent timed out before reaching a final answer."
What owning the loop buys you
Because the loop is your own code, you decide what happens on failure. A trace from a run with a weather-style tool shows why that matters.
In turn one, the model correctly works out that it needs a tool called get_planet_mass, but it writes the input with single quotes, {'planet': 'Earth'}. That isn't valid JSON, so the parser on the Python side raises a standard complaint: it expected property names in double quotes.
A typical script would die right there. This loop doesn't throw. It takes the raw error text and drops it into the conversation as the next Observation.
In turn two, the model reads that message and acknowledges in its thought that the input format was wrong and has to follow JSON rules. It reissues the call with {"planet": "Earth"}, the router runs the tool and the task completes.
Notice that nothing special was written for this case. There's no retry handler for malformed JSON, only the habit of returning errors as observations, and the model's own reading comprehension does the rest. That's what an internal monologue looks like in practice. A static planner would have crashed in turn one and needed a human to restart it. The ReAct loop let the model diagnose and repair its own syntax mid-run. It didn't just follow a plan, it reacted to what actually happened.
Where ReAct breaks in production
The code above feels almost magical the first time it runs. Before shipping it, you should know the two places it cracks.
The hallucination spiral. Sometimes a model gets stubbornly convinced that a broken tool is about to work. It calls get_weather, sees an error, calls get_weather again, sees the same error, and keeps going while your API credits drain. The defense is the max_turns=5 guard in the loop. A hard circuit breaker like that is non-negotiable, and no ReAct agent should be allowed to run unbounded.
Context bloat. Each cycle appends its thought, action and observation to the conversation. If a tool returns a 10,000-line JSON payload, all of it lands in the context window. By the next turn the model is drowning in data, its reasoning quality drops and it may lose track of the original question. Never pipe raw API responses or full web scrapes straight into an observation. Even a rough cap, such as the first couple of thousand characters plus a note that the rest was cut, beats flooding the model. Put a small middleware function in between that summarizes or truncates results first, so the monologue stays short and clean.
There's also a limit that no guard fixes. When a single agent has to juggle too many large tools and too much accumulated context, its reasoning gets cluttered and it drifts off task. A ReAct agent is a sharp tactician. It makes a poor executive.
Combining both styles
The two patterns solve different problems. Plan-and-execute gives structure, and ReAct gives resilience. Many production systems end up using both: a planner drafts the high-level steps, then hands each messy sub-task to its own ReAct agent that sorts out the details on the fly. Coordinating several agents like that, built from scratch with no framework, is the natural next step after this loop.