Most agent frameworks hide the reasoning-action cycle behind abstractions you’ll eventually need to debug. To implement ReAct loop from scratch, you only need an LLM with structured prompting, a tool executor, and a tight control loop. This tutorial builds a minimal but production-shaped version in Python that you can extend without adopting a heavy dependency.
Prerequisites
- Python 3.11 or newer
openaiPython package (v1.x)python-dotenvfor local env loading- An OpenAI-compatible API key. We’ll point the client at a gateway; n4n.ai provides one OpenAI-compatible endpoint that fronts 240+ models and falls back automatically when a provider is rate-limited.
- Basic comfort with regex and JSON.
Install dependencies:
pip install openai python-dotenv
Create a .env file:
OPENAI_API_KEY=sk-your-key
BASE_URL=https://api.n4n.ai/v1
MODEL=anthropic/claude-3.5-sonnet
We use a model that follows instructions well. The loop does not rely on native function calling—we parse plain text, which keeps the logic portable across any chat model.
The ReAct contract
ReAct interleaves reasoning traces with actions:
- Thought: the model explains what it needs or why.
- Action: the model names a registered tool.
- Action Input: the argument string for that tool.
- Observation: the string returned by executing the tool.
The model emits text in this shape. Your code parses it, runs the tool, and feeds the observation back into the same context. Repeat until the model emits Action: Finish. The discipline of the format is what makes the loop work; the model is not magic, it is constrained.
Step 1: Define tools
A tool is a named function with a description. Keep a registry so the prompt and executor stay in sync.
import math
TOOLS = {
"calculator": {
"description": "Evaluate a math expression safely.",
"func": lambda expr: str(eval(expr, {"__builtins__": {}}, {"math": math})),
},
"get_weather": {
"description": "Return fake weather for a city.",
"func": lambda city: f"It is 22C and sunny in {city}.",
},
}
def execute_tool(name: str, arg: str) -> str:
if name not in TOOLS:
return f"ERROR: unknown tool {name}"
try:
return TOOLS[name]["func"](arg)
except Exception as e:
return f"ERROR: {e}"
The eval call is sandboxed loosely for demo only. In production, use a real expression parser or a restricted interpreter. Tool outputs must be strings—never return objects that will blow up the context.
Step 2: LLM client setup
Use the OpenAI client with a custom base URL and a timeout.
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ["BASE_URL"],
timeout=30.0,
)
MODEL = os.environ["MODEL"]
If your gateway honors provider cache-control hints, prefix repeated system prompts with a cache marker per its docs. We keep it simple here.
Step 3: Prompt construction
The system prompt enforces the ReAct format. Include one few-shot example so the model doesn’t drift, and inject the live tool list.
def tool_descriptions() -> str:
return "\n".join(f"- {k}: {v['description']}" for k, v in TOOLS.items())
SYSTEM_PROMPT = """You are a ReAct agent. Use the following format exactly:
Thought: your reasoning
Action: tool_name
Action Input: argument
Observation: (provided after tool runs)
... (repeat)
Thought: I have the answer
Action: Finish
Action Input: final answer
Available tools:
{tools}
Example:
Thought: I need the weather in Paris.
Action: get_weather
Action Input: Paris
Observation: It is 22C and sunny in Paris.
Thought: I have the answer.
Action: Finish
Action Input: It is 22C and sunny in Paris.
"""
def build_messages(question: str, history: str) -> list:
system = SYSTEM_PROMPT.format(tools=tool_descriptions())
return [
{"role": "system", "content": system},
{"role": "user", "content": f"Question: {question}\n{history}"},
]
Dynamic tool injection means adding a new tool requires zero prompt edits elsewhere.
Step 4: Parse model output
We extract the last Action and Action Input blocks. Strict parsing rejects malformed turns.
import re
def parse_action_strict(text: str):
m1 = re.search(r"Action:\s*(\w+)", text)
m2 = re.search(r"Action Input:\s*(.+?)(?:\n|$)", text)
if not m1 or not m2:
return None, None
return m1.group(1).strip(), m2.group(1).strip()
If parsing fails, the loop should treat the output as a thought and continue, rather than crashing.
Step 5: The loop
This is the core. Limit iterations and log token usage for cost control.
def run_react(question: str, max_steps: int = 5):
history = ""
total_tokens = 0
for step in range(max_steps):
messages = build_messages(question, history)
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=0,
max_tokens=300,
)
total_tokens += resp.usage.total_tokens
output = resp.choices[0].message.content
print(f"--- Model output (step {step}) ---\n{output}\n")
action, action_input = parse_action_strict(output)
if action is None:
history += output + "\n"
continue
if action == "Finish":
print(f"Total tokens used: {total_tokens}")
return action_input
obs = execute_tool(action, action_input)
history += output + f"\nObservation: {obs}\n"
print(f"Total tokens used: {total_tokens}")
return "ERROR: max steps exceeded"
Checkpoint: run a query
if __name__ == "__main__":
answer = run_react("What is 2 * (3 + 4) and the weather in London?")
print("FINAL ANSWER:", answer)
Expected output (abridged):
--- Model output (step 0) ---
Thought: I need to compute the math expression.
Action: calculator
Action Input: 2 * (3 + 4)
--- Model output (step 1) ---
Thought: The result is 14. Now I need London weather.
Action: get_weather
Action Input: London
Observation: It is 22C and sunny in London.
Thought: I have both answers.
Action: Finish
Action Input: 2*(3+4)=14, London weather: 22C sunny.
Total tokens used: 412
FINAL ANSWER: 2*(3+4)=14, London weather: 22C sunny.
Your trace will differ in wording but must follow the schema. If the model skips Observation lines, that’s fine—your code supplies them.
Error handling and guardrails
The naive loop breaks if the model emits malformed text. Beyond strict parsing, add a retry wrapper:
def safe_parse(text: str):
action, inp = parse_action_strict(text)
if action is None:
# nudge the model back on track
return "Thought: I must output Action and Action Input.\n", None
return text, action
Also cap max_tokens per step and enforce a global token budget. If you use a gateway with per-token metering, the resp.usage field is already enough to track spend—no extra instrumentation needed.
Context management
Each iteration appends the full prior transcript. For long tasks, summarize older observations:
def compress_history(history: str, max_chars: int = 2000) -> str:
if len(history) <= max_chars:
return history
return "...(truncated)...\n" + history[-max_chars:]
Call this before build_messages if len(history) grows. The model does not need every byte of earlier tool output, just the conclusion.
Why this is enough to ship
You now implement ReAct loop from scratch without a framework. The moving parts are: prompt discipline, output parsing, tool dispatch, and iteration. Everything else in agent libraries is observability, retries, and model routing.
When you need to swap models or avoid rate limits, point the client at a gateway that honors routing directives. The loop code stays identical. That separation is the point: your reasoning-action logic is provider-agnostic, and the LLM is a commodity socket.
If you implement ReAct loop from scratch with more than three tools, the dynamic tool_descriptions() call becomes essential. The model will hallucinate tool names if the list is stale.
A note on tool design
Tools should return strings that are cheap to inject. Avoid dumping 10KB JSON into Observation; summarize. The model’s context window is your bottleneck, not the loop. A good tool returns exactly what the next reasoning step needs and nothing more.
For nested dependencies (tool A output feeds tool B), let the model chain them by reading its own observations. Do not build a DAG in code—ReAct’s strength is letting the model decide order.
Final checklist
- System prompt enforces Thought/Action/Observation.
- Tools return strings, never raise unhandled.
- Loop caps steps and tokens.
- Parser handles missing Action gracefully.
- You log usage for cost control.
- History compression exists for long runs.
That is the whole technique. Build from here.